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 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + 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*) 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*) 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*) 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 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_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 +#import "MLContact.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLChatViewHelper : NSObject + ++(void) toggleEncryptionForContact:(MLContact*) contact withSelf:(id) andSelf afterToggle:(void (^)(void)) afterToggle; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLChatViewHelper.m b/Monal/Classes/MLChatViewHelper.m new file mode 100644 index 0000000..b9cea10 --- /dev/null +++ b/Monal/Classes/MLChatViewHelper.m @@ -0,0 +1,36 @@ +// +// MLChatViewHelper.m +// Monal +// +// Created by Friedrich Altheide on 04.08.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLChatViewHelper.h" +#import "DataLayer.h" +#import "MLContact.h" + +@import UIKit.UIAlertController; + +@implementation MLChatViewHelper + ++(void) toggleEncryptionForContact:(MLContact*) contact withSelf:(id) andSelf afterToggle:(void (^)(void)) afterToggle +{ + // Update the encryption value in the caller class + if(![contact toggleEncryption:!contact.isEncrypted]) + { + // Show a warning when no device keys could be found and the user tries to enable encryption -> encryption is not possible + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Encryption Not Supported", @"") message:NSLocalizedString(@"This contact does not appear to have any devices that support encryption, please try again later if you think this is wrong.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + + // open the alert msg in the calling view controller + [andSelf presentViewController:alert animated:YES completion:nil]; + } + + // Call the code that should update the UI elements + afterToggle(); +} + +@end diff --git a/Monal/Classes/MLConstants.h b/Monal/Classes/MLConstants.h new file mode 100644 index 0000000..199900b --- /dev/null +++ b/Monal/Classes/MLConstants.h @@ -0,0 +1,237 @@ +// +// MLConstants.h +// Monal +// +// Created by Anurodh Pokharel on 7/13/13. +// +// + +#import +#import +#import "MLHandler.h" + +@import CocoaLumberjack; +#define LOG_FLAG_STDERR (1 << 5) +#define LOG_FLAG_STDOUT (1 << 6) +#define LOG_LEVEL_STDERR (DDLogLevelVerbose | LOG_FLAG_STDERR) +#define LOG_LEVEL_STDOUT (LOG_LEVEL_STDERR | LOG_FLAG_STDOUT) +//behave like DDLogError and flush log on DDLogStderr +#define DDLogStderr(frmt, ...) do { LOG_MAYBE(NO, ddLogLevel, LOG_FLAG_STDERR, 0, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__); [DDLog flushLog]; } while(0) +#define DDLogStdout(frmt, ...) LOG_MAYBE(NO, ddLogLevel, LOG_FLAG_STDOUT, 0, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__) +static const DDLogLevel ddLogLevel = LOG_LEVEL_STDOUT; +#import "MLLogFileManager.h" + +@import PromiseKit; +#define PMKHangEnum(promise) (((NSNumber*)PMKHang(promise)).integerValue) +#define PMKHangBool(promise) (((NSNumber*)PMKHang(promise)).boolValue) +#define PMKHangInt(promise) (((NSNumber*)PMKHang(promise)).intValue) +#define PMKHangDouble(promise) (((NSNumber*)PMKHang(promise)).doubleValue) + +//configure app group constants +#ifdef IS_ALPHA + #define kAppGroup @"group.monalalpha" + #define kMonalOpenURL [NSURL URLWithString:@"monalAlphaOpen://"] + #define kBackgroundProcessingTask @"im.monal.alpha.process" + #define kBackgroundRefreshingTask @"im.monal.alpha.refresh" +#elif defined(IS_QUICKSY) + #define kAppGroup @"group.quicksy" + #define kMonalOpenURL [NSURL URLWithString:@"quicksyOpen://"] + #define kBackgroundProcessingTask @"im.monal.process" + #define kBackgroundRefreshingTask @"im.monal.refresh" +#else + #define kAppGroup @"group.monal" + #define kMonalOpenURL [NSURL URLWithString:@"monalOpen://"] + #define kBackgroundProcessingTask @"im.monal.process" + #define kBackgroundRefreshingTask @"im.monal.refresh" +#endif + +#define kMonalKeychainName @"Monal" + +//this is in seconds +#if TARGET_OS_MACCATALYST + #define SHORT_PING 4.0 + #define LONG_PING 8.0 + #define MUC_PING 600 + #define BGFETCH_DEFAULT_INTERVAL 3600*1 +#else + #define SHORT_PING 4.0 + #define LONG_PING 8.0 + #define MUC_PING 3600 + #define BGFETCH_DEFAULT_INTERVAL 3600*3 +#endif + +// #define defineBlockType(name, returntype, ...) \ +// typedef returntype (^name)(__VA_ARGS__); \ +// name _Nonnull castTo_##name(id _Nonnull block) { return block; } +// +// #ifndef blocktypes +// defineBlockType(monal_new_void_block_t, void, void); +// #endif + +@class MLContact; +@class MLDelayableTimer; + +//some typedefs used throughout the project +typedef void (^contactCompletion)(MLContact* _Nonnull selectedContact) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); +typedef void (^accountCompletion)(NSInteger accountRow) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); +typedef void (^monal_void_block_t)(void) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); +typedef void (^monal_id_block_t)(id _Nonnull) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); +typedef id _Nullable (^monal_id_returning_void_block_t)(void) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); +typedef id _Nullable (^monal_id_returning_id_block_t)(id _Nonnull) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); +typedef void (^monal_upload_completion_t)(NSString* _Nullable url, NSString* _Nullable mimeType, NSNumber* _Nullable size, NSError* _Nullable error) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); + +typedef NS_ENUM(NSUInteger, MLAudioState) { + MLAudioStateNormal, + MLAudioStateCall, +}; + +//some useful macros +#define weakify(var) __weak __typeof__(var) AHKWeak_##var = var +#define strongify(var) _Pragma("clang diagnostic push") _Pragma("clang diagnostic ignored \"-Wshadow\"") __strong __typeof__(var) var = AHKWeak_##var; _Pragma("clang diagnostic pop") +#define nilWrapper(var) (var == nil ? (id)[NSNull null] : (id)var) +#define nilExtractor(var) ((id)var == [NSNull null] ? nil : var) +#define nilDefault(var, def) (var == nil || (id)var == [NSNull null] ? def : var) +#define nilDefaultEnum(var, def) (((NSNumber*)nilDefault(var, def)).integerValue) +#define nilDefaultBool(var, def) (((NSNumber*)nilDefault(var, def)).boolValue) +#define nilDefaultInt(var, def) (((NSNumber*)nilDefault(var, def)).intValue) +#define nilDefaultDouble(var, def) (((NSNumber*)nilDefault(var, def)).doubleValue) +#define emptyDefault(var, eq, def) (var == nil || (id)var == [NSNull null] || [var isEqual:eq] ? def : var) +#define updateIfIdNotEqual(a, b) if(a != b && ![a isEqual:b]) a = b +#define updateIfPrimitiveNotEqual(a, b) if(a != b) a = b +#define var __auto_type +#define let const __auto_type +#define bool2str(b) (b ? @"YES" : @"NO") + +#define min(a, b) \ + ({ __typeof__ (a) _a = (a); \ + __typeof__ (b) _b = (b); \ + _a < _b ? _a : _b; }) +#define max(a, b) \ + ({ __typeof__ (a) _a = (a); \ + __typeof__ (b) _b = (b); \ + _a > _b ? _a : _b; }) + +//make sure we don't define this twice +#ifndef STRIP_PARENTHESES + //see https://stackoverflow.com/a/62984543/3528174 + #define STRIP_PARENTHESES(X) __ESC(__ISH X) + #define __ISH(...) __ISH __VA_ARGS__ + #define __ESC(...) __ESC_(__VA_ARGS__) + #define __ESC_(...) __VAN ## __VA_ARGS__ + #define __VAN__ISH +#endif + +// https://clang-analyzer.llvm.org/faq.html#unlocalized_string +__attribute__((annotate("returns_localized_nsstring"))) +static inline NSString* _Nonnull LocalizationNotNeeded(NSString* _Nonnull s) +{ + return s; +} + +#define kServerDoesNotFollowXep0440Error @"__incomplete XEP-0388 support, XEP-0440 MUST be implemented and this mandates that servers MUST at least implement tls-server-end-point__" + +//some xmpp related constants +#define kId @"id" +#define kMessageId @"kMessageId" + +#define kRegisterNameSpace @"jabber:iq:register" + +//all other constants needed +#define kMonalConnectivityChange @"kMonalConnectivityChange" +#define kMonalCallRemoved @"kMonalCallRemoved" +#define kMonalCallAdded @"kMonalCallAdded" +#define kMonalIncomingJMIStanza @"kMonalIncomingJMIStanza" +#define kMonalIncomingVoipCall @"kMonalIncomingVoipCall" +#define kMonalIncomingSDP @"kMonalIncomingSDP" +#define kMonalIncomingICECandidate @"kMonalIncomingICECandidate" +#define kMonalWillBeFreezed @"kMonalWillBeFreezed" +#define kMonalIsFreezed @"kMonalIsFreezed" +#define kMonalNewMessageNotice @"kMonalNewMessageNotice" +#define kMonalMucSubjectChanged @"kMonalMucSubjectChanged" +#define kMonalDeletedMessageNotice @"kMonalDeletedMessageNotice" +#define kMonalDisplayedMessagesNotice @"kMonalDisplayedMessagesNotice" +#define kMonalHistoryMessagesNotice @"kMonalHistoryMessagesNotice" +#define kMLMessageSentToContact @"kMLMessageSentToContact" +#define kMonalSentMessageNotice @"kMonalSentMessageNotice" +#define kMonalMessageFiletransferUpdateNotice @"kMonalMessageFiletransferUpdateNotice" +#define kMonalAccountDiscoDone @"kMonalAccountDiscoDone" + +#define kMonalNewPresenceNotice @"kMonalNewPresenceNotice" +#define kMonalLastInteractionUpdatedNotice @"kMonalLastInteractionUpdatedNotice" +#define kMonalMessageReceivedNotice @"kMonalMessageReceivedNotice" +#define kMonalMessageDisplayedNotice @"kMonalMessageDisplayedNotice" +#define kMonalMessageErrorNotice @"kMonalMessageErrorNotice" +#define kMonalReceivedMucInviteNotice @"kMonalReceivedMucInviteNotice" +#define kXMPPError @"kXMPPError" +#define kScheduleBackgroundTask @"kScheduleBackgroundTask" +#define kMonalUpdateUnread @"kMonalUpdateUnread" + +#define kMLIsLoggedInNotice @"kMLIsLoggedInNotice" +#define kMLResourceBoundNotice @"kMLResourceBoundNotice" +#define kMonalFinishedCatchup @"kMonalFinishedCatchup" +#define kMonalFinishedOmemoBundleFetch @"kMonalFinishedOmemoBundleFetch" +#define kMonalOmemoStateUpdated @"kMonalOmemoStateUpdated" +#define kMonalUpdateBundleFetchStatus @"kMonalUpdateBundleFetchStatus" +#define kMonalOmemoFetchingStateUpdate @"kMonalOmemoFetchingStateUpdate" +#define kMonalIdle @"kMonalIdle" +#define kMonalFiletransfersIdle @"kMonalFiletransfersIdle" +#define kMonalNotIdle @"kMonalNotIdle" + +#define kMonalBackgroundChanged @"kMonalBackgroundChanged" +#define kMLMAMPref @"kMLMAMPref" + +#define kMonalAccountStatusChanged @"kMonalAccountStatusChanged" + +#define kMonalRefresh @"kMonalRefresh" +#define kMonalContactRefresh @"kMonalContactRefresh" +#define kMonalXmppUserSoftWareVersionRefresh @"kMonalXmppUserSoftWareVersionRefresh" +#define kMonalBlockListRefresh @"kMonalBlockListRefresh" +#define kMonalContactRemoved @"kMonalContactRemoved" +#define kMonalMucParticipantsAndMembersUpdated @"kMonalMucParticipantsAndMembersUpdated" + +#define kMucTypeGroup @"group" +#define kMucTypeChannel @"channel" + +#define kMucRoleModerator @"moderator" +#define kMucRoleNone @"none" +#define kMucRoleParticipant @"participant" +#define kMucRoleVisitor @"visitor" + +#define kMucAffiliationOwner @"owner" +#define kMucAffiliationAdmin @"admin" +#define kMucAffiliationMember @"member" +#define kMucAffiliationOutcast @"outcast" +#define kMucAffiliationNone @"none" +#define kMucActionShowProfile @"profile" +#define kMucActionReinvite @"reinvite" + +// max count of char's in a single message (both: sending and receiving) +#define kMonalChatMaxAllowedTextLen 2048 + +#if TARGET_OS_MACCATALYST +#define kMonalBackscrollingMsgCount 75 +#else +#define kMonalBackscrollingMsgCount 50 +#endif + +//contact cells +#define kusernameKey @"username" +#define kfullNameKey @"fullName" +#define kaccountIDKey @"accountID" +#define kstateKey @"state" +#define kstatusKey @"status" + +//info cells +#define kaccountNameKey @"accountName" +#define kinfoTypeKey @"type" +#define kinfoStatusKey @"status" + +//use this to completely disable omemo in build +//#ifndef DISABLE_OMEMO +//#define DISABLE_OMEMO 1 +//#endif + +//build MLXMLNode query statistics (will only optimize MLXMLNode queries if *not* defined) +//#define QueryStatistics 1 + +#define geoPattern @"^geo:(-?(?:90|[1-8][0-9]|[0-9])(?:\\.[0-9]{1,32})?),(-?(?:180|1[0-7][0-9]|[0-9]{1,2})(?:\\.[0-9]{1,32})?)(;.*)?([?].*)?$" diff --git a/Monal/Classes/MLContact.h b/Monal/Classes/MLContact.h new file mode 100644 index 0000000..3078f16 --- /dev/null +++ b/Monal/Classes/MLContact.h @@ -0,0 +1,125 @@ +// +// MLContact.h +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSString* const kSubBoth; +FOUNDATION_EXPORT NSString* const kSubNone; +FOUNDATION_EXPORT NSString* const kSubTo; +FOUNDATION_EXPORT NSString* const kSubFrom; +FOUNDATION_EXPORT NSString* const kSubRemove; + +FOUNDATION_EXPORT NSString* const kAskSubscribe; + +@class xmpp; +@class MLMessage; +@class UIImage; + +@interface MLContact : NSObject ++(MLContact*) makeDummyContact:(int) type; + ++(BOOL) supportsSecureCoding; + ++(NSString*) ownDisplayNameForAccount:(xmpp*) account; + +@property (readonly) NSString* id; //for Identifiable protocol + +@property (nonatomic, readonly) BOOL isSelfChat; +@property (nonatomic, readonly) BOOL isInRoster; +@property (nonatomic, readonly) BOOL isSubscribedTo; +@property (nonatomic, readonly) BOOL isSubscribedFrom; +@property (nonatomic, readonly) BOOL isSubscribedBoth; +@property (nonatomic, readonly) BOOL hasIncomingContactRequest; +@property (nonatomic, readonly) BOOL hasOutgoingContactRequest; + +-(BOOL) isEqualToContact:(MLContact*) contact; +-(BOOL) isEqualToMessage:(MLMessage*) message; +-(BOOL) isEqual:(id _Nullable) object; + ++(MLContact*) createContactFromJid:(NSString*) jid andAccountID:(NSNumber*) accountID; + +/** + account number in the database should be an integer + */ +@property (nonatomic, readonly) NSNumber* accountID; +@property (nonatomic, readonly) NSString* contactJid; +@property (nonatomic, readonly, copy) UIImage* avatar; +@property (nonatomic, readonly) BOOL hasAvatar; +@property (nonatomic, readonly) NSString* fullName; +@property (nonatomic, readonly) xmpp* _Nullable account; +@property (nonatomic, readonly) NSSet* rosterGroups; +/** + usually user assigned nick name + */ +@property (nonatomic, readonly) NSString* nickName; +@property (nonatomic, strong) NSString* nickNameView; +@property (nonatomic, strong) NSString* fullNameView; + +/** + xmpp state text + */ +@property (nonatomic, copy) NSString* state; + +/** + xmpp status message + */ +@property (nonatomic, copy) NSString* statusMessage; +@property (nonatomic, readonly) NSDate* _Nullable lastInteractionTime; +@property (nonatomic, readonly) BOOL isTyping; + +/** + used to display the badge on a row + */ +@property (nonatomic, readonly) NSInteger unreadCount; + +@property (nonatomic, readonly) BOOL isPinned; +@property (nonatomic, readonly) BOOL isBlocked; +@property (nonatomic, readonly) BOOL isMuted; +@property (nonatomic, readonly) BOOL isActiveChat; +@property (nonatomic, assign) BOOL isEncrypted; + +@property (nonatomic, readonly) BOOL isMuc; +@property (nonatomic, readonly) NSString* groupSubject; +@property (nonatomic, readonly) NSString* mucType; +@property (nonatomic, readonly) NSString* accountNickInGroup; +@property (nonatomic, readonly) BOOL isMentionOnly; + +@property (nonatomic, readonly) NSString* subscription; //roster subbscription state +@property (nonatomic, readonly) NSString* ask; //whether we have tried to subscribe + +@property (nonatomic, readonly) NSString* contactDisplayName; +@property (nonatomic, readonly) NSString* contactDisplayNameWithoutSelfnotesPrefix; + +-(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName; +-(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName andSelfnotesPrefix:(BOOL) hasSelfnotesPrefix; +-(void) updateWithContact:(MLContact*) contact; +-(void) refresh; +-(void) updateUnreadCount; + +@property (strong, readonly) NSString* description; + + +// *** mutating methods (for swiftui etc.) below *** + +-(void) toggleMute:(BOOL) mute; +-(void) toggleMentionOnly:(BOOL) mentionOnly; +-(BOOL) toggleEncryption:(BOOL) encrypt; +-(void) togglePinnedChat:(BOOL) pinned; +-(BOOL) toggleBlocked:(BOOL) block; +-(void) removeFromRoster; +-(void) addToRoster; +-(void) clearHistory; +-(void) removeShareInteractions; + +-(NSUInteger) hash; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLContact.m b/Monal/Classes/MLContact.m new file mode 100644 index 0000000..63de457 --- /dev/null +++ b/Monal/Classes/MLContact.m @@ -0,0 +1,868 @@ +// +// MLContact.m +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLContact.h" +#import "MLMessage.h" +#import "HelperTools.h" +#import "DataLayer.h" +#import "xmpp.h" +#import "MLXMPPManager.h" +#import "MLOMEMO.h" +#import "MLNotificationQueue.h" +#import "MLImageManager.h" +#import "MLVoIPProcessor.h" +#import "MonalAppDelegate.h" +#import "MLMucProcessor.h" + +@import Intents; + +NSString* const kSubBoth = @"both"; +NSString* const kSubNone = @"none"; +NSString* const kSubTo = @"to"; +NSString* const kSubFrom = @"from"; +NSString* const kSubRemove = @"remove"; +NSString* const kAskSubscribe = @"subscribe"; + +static NSMutableDictionary* _singletonCache; + +@interface MLContact () +{ + NSInteger _unreadCount; + monal_void_block_t _cancelNickChange; + monal_void_block_t _cancelFullNameChange; + UIImage* _avatar; +} +@property (nonatomic, assign) BOOL isSelfChat; +@property (nonatomic, assign) BOOL isInRoster; +@property (nonatomic, assign) BOOL isSubscribedTo; +@property (nonatomic, assign) BOOL isSubscribedFrom; +@property (nonatomic, assign) BOOL hasIncomingContactRequest; + +@property (nonatomic, strong) NSNumber* accountID; +@property (nonatomic, strong) NSString* contactJid; +@property (nonatomic, strong) NSString* fullName; +@property (nonatomic, strong) NSString* nickName; +@property (nonatomic, strong) xmpp* account; +@property (nonatomic, strong) NSSet* rosterGroups; + +@property (nonatomic, strong) NSDate* _Nullable lastInteractionTime; +@property (nonatomic, assign) BOOL isTyping; + +@property (nonatomic, assign) NSInteger unreadCount; + +@property (nonatomic, assign) BOOL isPinned; +@property (nonatomic, assign) BOOL isBlocked; +@property (nonatomic, assign) BOOL isMuted; +@property (nonatomic, assign) BOOL isActiveChat; + +@property (nonatomic, assign) BOOL isMuc; +@property (nonatomic, strong) NSString* groupSubject; +@property (nonatomic, strong) NSString* mucType; +@property (nonatomic, strong) NSString* accountNickInGroup; +@property (nonatomic, assign) BOOL isMentionOnly; + +@property (nonatomic, strong) NSString* subscription; +@property (nonatomic, strong) NSString* ask; + +@property (nonatomic, strong) NSString* contactDisplayName; +@end + +@implementation MLContact + ++(void) initialize +{ + _singletonCache = [NSMutableDictionary new]; +} + ++(MLContact*) makeDummyContact:(int) type +{ + if(type == 1) + { + return [self contactFromDictionary:@{ + @"buddy_name": @"user@example.org", + @"nick_name": @"", + @"full_name": @"", + @"subscription": kSubBoth, + @"ask": @"", + @"account_id": @1, + //@"muc_subject": nil, + //@"muc_nick": nil, + @"Muc": @NO, + @"pinned": @NO, + @"encrypt": @NO, + @"muted": @NO, + @"status": @"", + @"state": @"online", + @"count": @1, + @"isActiveChat": @YES, + @"lastInteraction": [[NSDate date] initWithTimeIntervalSince1970:0], + @"rosterGroups": [NSSet new], + }]; + } + else if(type == 2) + { + return [self contactFromDictionary:@{ + @"buddy_name": @"group@example.org", + @"nick_name": @"", + @"full_name": @"Die coole Gruppe", + @"subscription": kSubBoth, + @"ask": @"", + @"account_id": @1, + //@"muc_subject": nil, + @"muc_nick": @"my_group_nick", + @"muc_type": kMucTypeGroup, + @"Muc": @YES, + @"pinned": @NO, + @"encrypt": @NO, + @"muted": @NO, + @"status": @"", + @"state": @"online", + @"count": @2, + @"isActiveChat": @YES, + @"lastInteraction": [[NSDate date] initWithTimeIntervalSince1970:1640153174], + @"rosterGroups": [NSSet new], + }]; + } + else if(type == 3) + { + return [self contactFromDictionary:@{ + @"buddy_name": @"channel@example.org", + @"nick_name": @"", + @"full_name": @"Der coolste Channel überhaupt", + @"subscription": kSubBoth, + @"ask": @"", + @"account_id": @1, + //@"muc_subject": nil, + @"muc_nick": @"my_channel_nick", + @"muc_type": kMucTypeChannel, + @"Muc": @YES, + @"pinned": @NO, + @"encrypt": @NO, + @"muted": @NO, + @"status": @"", + @"state": @"online", + @"count": @3, + @"isActiveChat": @YES, + @"lastInteraction": [[NSDate date] initWithTimeIntervalSince1970:1640157074], + @"rosterGroups": [NSSet new], + }]; + } + else + { + return [self contactFromDictionary:@{ + @"buddy_name": @"user2@example.org", + @"nick_name": @"", + @"full_name": @"Zweiter User mit Roster Name", + @"subscription": kSubBoth, + @"ask": @"", + @"account_id": @1, + //@"muc_subject": nil, + //@"muc_nick": nil, + @"Muc": @NO, + @"pinned": @NO, + @"encrypt": @NO, + @"muted": @NO, + @"status": @"", + @"state": @"online", + @"count": @4, + @"isActiveChat": @YES, + @"lastInteraction": [[NSDate date] initWithTimeIntervalSince1970:1640157174], + @"rosterGroups": [NSSet new], + }]; + } +} + ++(BOOL) supportsSecureCoding +{ + return YES; +} + ++(NSString*) ownDisplayNameForAccount:(xmpp*) account +{ + NSDictionary* accountDic = [[DataLayer sharedInstance] detailsForAccount:account.accountID]; + NSString* displayName = accountDic[kRosterName]; + DDLogVerbose(@"Own nickname in accounts table %@: '%@'", account.accountID, displayName); + if(!displayName || !displayName.length) + { + // default is local part, see https://docs.modernxmpp.org/client/design/#contexts + NSDictionary* jidParts = [HelperTools splitJid:account.connectionProperties.identity.jid]; + displayName = jidParts[@"node"]; + } + DDLogVerbose(@"Calculated ownDisplayName for '%@': %@", account.connectionProperties.identity.jid, displayName); + return nilDefault(displayName, @""); +} + ++(MLContact*) createContactFromDatabaseWithJid:(NSString*) jid andAccountID:(NSNumber*) accountID +{ + NSDictionary* contactDict = [[DataLayer sharedInstance] contactDictionaryForUsername:jid forAccount:accountID]; + MLContact* retval; + // check if we know this contact and return a dummy one if not + if(contactDict == nil) + { + DDLogInfo(@"Returning dummy MLContact for %@ on accountID %@", jid, accountID); + retval = [self contactFromDictionary:@{ + @"buddy_name": jid.lowercaseString, + @"nick_name": @"", + @"full_name": @"", + @"subscription": kSubNone, + @"ask": @"", + @"account_id": accountID, + //@"muc_subject": nil, + //@"muc_nick": nil, + @"Muc": @NO, + @"mentionOnly": @NO, + @"pinned": @NO, + @"encrypt": @NO, + @"muted": @NO, + @"status": @"", + @"state": @"offline", + @"count": @0, + @"isActiveChat": @NO, + @"lastInteraction": nilWrapper(nil), + @"rosterGroups": [NSSet set], + }]; + } + else + { + retval = [self contactFromDictionary:contactDict]; + } + //initialize the blocking state, which is not stored in the buddylist table + retval.isBlocked = [[DataLayer sharedInstance] isBlockedContact:retval]; + return retval; +} + ++(MLContact*) createContactFromJid:(NSString*) jid andAccountID:(NSNumber*) accountID +{ + MLAssert(jid != nil, @"jid must not be nil"); + MLAssert(accountID != nil && accountID.intValue >= 0, @"accountID must not be nil and > 0"); + + NSString* cacheKey = [NSString stringWithFormat:@"%@|%@", accountID, jid]; + @synchronized(_singletonCache) { + if(_singletonCache[cacheKey] != nil) + { + MLContact* obj = ((WeakContainer*)_singletonCache[cacheKey]).obj; + if(obj != nil) + return obj; + else + [_singletonCache removeObjectForKey:cacheKey]; + } + + MLContact* retval = [self createContactFromDatabaseWithJid:jid andAccountID:accountID]; + + _singletonCache[cacheKey] = [[WeakContainer alloc] initWithObj:retval]; + return retval; + } +} + +-(instancetype) init +{ + self = [super init]; + //watch for all sorts of changes and update our singleton dynamically + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleLastInteractionTimeUpdate:) name:kMonalLastInteractionUpdatedNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleBlockListRefresh:) name:kMonalBlockListRefresh object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(refresh) name:kMonalRefresh object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleContactRefresh:) name:kMonalContactRefresh object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleContactRefresh:) name:kMonalContactRemoved object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMucSubjectChange:) name:kMonalMucSubjectChanged object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateUnreadCount) name:kMonalNewMessageNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateUnreadCount) name:kMonalDeletedMessageNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateUnreadCount) name:kMLMessageSentToContact object:nil]; + return self; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(void) handleLastInteractionTimeUpdate:(NSNotification*) notification +{ + NSDictionary* data = notification.userInfo; + NSNumber* notificationAccountID = data[@"accountID"]; + + if(![self.contactJid isEqualToString:data[@"jid"]] || self.accountID.intValue != notificationAccountID.intValue) + return; // ignore other accounts or contacts + + self.isTyping = [data[@"isTyping"] boolValue]; + + if(data[@"lastInteraction"] == nil) + return; + //this will be nil if "urn:xmpp:idle:1" is not supported by any of the contact's devices + DDLogVerbose(@"Updating lastInteractionTime=%@ of %@", data[@"lastInteraction"], self); + self.lastInteractionTime = nilExtractor(data[@"lastInteraction"]); +} + +-(void) handleBlockListRefresh:(NSNotification*) notification +{ + NSDictionary* data = notification.userInfo; + NSNumber* notificationAccountID = data[@"accountID"]; + if(self.accountID.intValue != notificationAccountID.intValue) + return; // ignore other accounts + self.isBlocked = [[DataLayer sharedInstance] isBlockedContact:self]; + DDLogInfo(@"Updated the blocking state of contact %@ => isBlocked=%@", self, bool2str(self.isBlocked)); +} + +-(void) handleContactRefresh:(NSNotification*) notification +{ + NSDictionary* data = notification.userInfo; + MLContact* contact = data[@"contact"]; + if(![self.contactJid isEqualToString:contact.contactJid] || self.accountID.intValue != contact.accountID.intValue) + return; // ignore other accounts or contacts + [self refresh]; + [self updateUnreadCount]; + //only handle avatar updates if the property was already used and the old avatar is cached in this contact + if(_avatar != nil) + { + UIImage* newAvatar = [[MLImageManager sharedInstance] getIconForContact:self]; + if(newAvatar != self->_avatar) + { + DDLogDebug(@"Setting new avatar for %@", self); + self.avatar = newAvatar; //use self.avatar instead of _avatar to make sure KVO works properly + } + } +} + +-(void) handleMucSubjectChange:(NSNotification*) notification +{ + xmpp* account = notification.object; + NSString* room = notification.userInfo[@"room"]; + NSString* subject = notification.userInfo[@"subject"]; + if(![self.contactJid isEqualToString:room] || self.accountID.intValue != account.accountID.intValue) + return; // ignore other accounts or contacts + self.groupSubject = nilDefault(subject, @""); +} + +-(void) refresh +{ + [self updateWithContact:[[self class] createContactFromDatabaseWithJid:self.contactJid andAccountID:self.accountID]]; +} + +-(void) updateUnreadCount +{ + _unreadCount = -1; // mark it as "uncached" --> will be recalculated on next access +} + +-(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName; +{ + return [self contactDisplayNameWithFallback:fallbackName andSelfnotesPrefix:YES]; +} + +-(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName andSelfnotesPrefix:(BOOL) hasSelfnotesPrefix +{ + //DDLogVerbose(@"Calculating contact display name..."); + NSString* displayName; + if(!self.isSelfChat) + { + if(fallbackName == nil) + { + //default is local part, see https://docs.modernxmpp.org/client/design/#contexts + NSDictionary* jidParts = [HelperTools splitJid:self.contactJid]; + fallbackName = jidParts[@"host"]; + if(jidParts[@"node"] != nil) + fallbackName = jidParts[@"node"]; + } + + if(self.nickName && self.nickName.length > 0) + { + //DDLogVerbose(@"Using nickName: %@", self.nickName); + displayName = self.nickName; + } + else if(self.fullName && self.fullName.length > 0) + { + //DDLogVerbose(@"Using fullName: %@", self.fullName); + displayName = self.fullName; + } + else + { + //DDLogVerbose(@"Using fallback: %@", fallbackName); + displayName = fallbackName; + } + } + else + { + xmpp* account = self.account; + if(hasSelfnotesPrefix) + { + //add "Note to self: " prefix for selfchats + if([[DataLayer sharedInstance] enabledAccountCnts].intValue > 1) + displayName = [NSString stringWithFormat:NSLocalizedString(@"Notes to self: %@", @""), [[self class] ownDisplayNameForAccount:account]]; + else + displayName = NSLocalizedString(@"Notes to self", @""); + } + else + displayName = [[self class] ownDisplayNameForAccount:account]; + } + + DDLogVerbose(@"Calculated contactDisplayName for '%@': %@", self.contactJid, displayName); + MLAssert(displayName != nil, @"Display name should never be nil!", (@{ + @"jid": nilWrapper(self.contactJid), + @"nickName": nilWrapper(self.nickName), + @"fullName": nilWrapper(self.fullName), + @"fallbackName": nilWrapper(fallbackName) + })); + return displayName; +} + +-(NSString*) contactDisplayName +{ + return [self contactDisplayNameWithFallback:nil]; +} + ++(NSSet*) keyPathsForValuesAffectingContactDisplayName +{ + return [NSSet setWithObjects:@"nickName", @"fullName", @"contactJid", nil]; +} + +-(NSString*) contactDisplayNameWithoutSelfnotesPrefix +{ + return [self contactDisplayNameWithFallback:nil andSelfnotesPrefix:NO]; +} + ++(NSSet*) keyPathsForValuesAffectingContactDisplayNameWithoutSelfnotesPrefix +{ + return [NSSet setWithObjects:@"nickName", @"fullName", @"contactJid", nil]; +} + +-(NSString*) nickNameView +{ + return nilDefault(self.nickName, @""); +} + +-(void) setNickNameView:(NSString*) name +{ + MLAssert(!self.isMuc, @"Using nickNameView only allowed for 1:1 contacts!", (@{@"contact": self})); + if([self.nickName isEqualToString:name] || name == nil) + return; //no change at all + self.nickName = name; + // abort old change timer and start a new one + if(_cancelNickChange) + _cancelNickChange(); + // delay changes because we don't want to update the roster on our server too often while typing + _cancelNickChange = createTimer(2.0, (^{ + xmpp* account = self.account; + [account updateRosterItem:self withName:self.nickName]; + })); +} + ++(NSSet*) keyPathsForValuesAffectingNickNameView +{ + return [NSSet setWithObjects:@"nickName", nil]; +} + +-(NSString*) fullNameView +{ + return nilDefault(self.fullName, @""); +} + +-(void) setFullNameView:(NSString*) name +{ + MLAssert(self.isMuc, @"Using fullNameView only allowed for mucs!", (@{@"contact": self})); + if([self.fullName isEqualToString:name] || name == nil) + return; //no change at all + self.fullName = name; + xmpp* account = self.account; + [[DataLayer sharedInstance] setFullName:self.fullName forContact:self.contactJid andAccount:account.accountID]; + // abort old change timer and start a new one + if(_cancelFullNameChange) + _cancelFullNameChange(); + // delay changes because we don't want to update the roster on our server too often while typing + _cancelFullNameChange = createTimer(2.0, (^{ + [account.mucProcessor changeNameOfMuc:self.contactJid to:self.fullName]; + })); +} + ++(NSSet*) keyPathsForValuesAffectingFullNameView +{ + return [NSSet setWithObjects:@"fullName", nil]; +} + +-(UIImage*) avatar +{ + // return already cached image + if(_avatar != nil) + return _avatar; + // load avatar from MLImageManager (use self.avatar instead of _avatar to make sure KVO works properly) + self.avatar = [[MLImageManager sharedInstance] getIconForContact:self]; + return _avatar; +} + +-(void) setAvatar:(UIImage*) avatar +{ + if(avatar != nil) + _avatar = avatar; + else + _avatar = [UIImage new]; //empty dummy image, to not save nil (should never happen, MLImageManager has default images) +} + +-(BOOL) hasAvatar +{ + return [[MLImageManager sharedInstance] hasIconForContact:self]; +} + +-(BOOL) isSelfChat +{ + xmpp* account = self.account; + return [self.contactJid isEqualToString:account.connectionProperties.identity.jid]; +} + ++(NSSet*) keyPathsForValuesAffectingIsSelfChat +{ + return [NSSet setWithObjects:@"contactJid", @"accountID", nil]; +} + +-(BOOL) isInRoster +{ + //either we already allowed each other or we allow this contact and asked them to allow us + //--> if isInRoster is true this is displayed as "remove contact" in contact details, otherwise it will be displayed as "add contact" + //(mucs have a subscription of 'both', ensured by the datalayer) + return [self.subscription isEqualToString:kSubBoth] || ([self.subscription isEqualToString:kSubFrom] && [self.ask isEqualToString:kAskSubscribe]); +} + ++(NSSet*) keyPathsForValuesAffectingIsInRoster +{ + return [NSSet setWithObjects:@"subscription", @"ask", nil]; +} + +-(BOOL) isSubscribedTo +{ + return [self.subscription isEqualToString:kSubBoth] + || [self.subscription isEqualToString:kSubTo]; +} + ++(NSSet*) keyPathsForValuesAffectingIsSubscribedTo +{ + return [NSSet setWithObjects:@"subscription", nil]; +} + +-(BOOL) isSubscribedFrom +{ + return [self.subscription isEqualToString:kSubBoth] + || [self.subscription isEqualToString:kSubFrom]; +} + ++(NSSet*) keyPathsForValuesAffectingIsSubscribedFrom +{ + return [NSSet setWithObjects:@"subscription", nil]; +} + +-(BOOL) isSubscribedBoth +{ + return [self.subscription isEqualToString:kSubBoth]; +} + ++(NSSet*) keyPathsForValuesAffectingIsSubscribedBoth +{ + return [NSSet setWithObjects:@"subscription", nil]; +} + +-(BOOL) hasIncomingContactRequest +{ + return self.isMuc == NO && [[DataLayer sharedInstance] hasContactRequestForContact:self]; +} + ++(NSSet*) keyPathsForValuesAffectingHasIncomingContactRequest +{ + return [NSSet setWithObjects:@"isMuc", nil]; +} + +-(BOOL) hasOutgoingContactRequest +{ + return self.isMuc == NO && [self.ask isEqualToString:kAskSubscribe]; +} + ++(NSSet*) keyPathsForValuesAffectingHasOutgoingContactRequest +{ + return [NSSet setWithObjects:@"isMuc", @"ask", nil]; +} + +-(xmpp* _Nullable) account +{ + return [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; +} + ++(NSSet*) keyPathsForValuesAffectingAccount +{ + return [NSSet setWithObject:@"accountID"]; +} + +// this will cache the unread count on first access +-(NSInteger) unreadCount +{ + if(_unreadCount == -1) + _unreadCount = [[[DataLayer sharedInstance] countUserUnreadMessages:self.contactJid forAccount:self.accountID] integerValue]; + return _unreadCount; +} + +-(void) removeShareInteractions +{ + [INInteraction deleteInteractionsWithIdentifiers:@[[NSString stringWithFormat:@"%@|%@", self.accountID, self.contactJid]] completion:^(NSError* error) { + if(error != nil) + DDLogError(@"Could not delete all SiriKit interactions: %@", error); + }]; +} + +-(void) toggleMute:(BOOL) mute +{ + if(self.isMuted == mute) + return; + if(mute) + [[DataLayer sharedInstance] muteContact:self]; + else + [[DataLayer sharedInstance] unMuteContact:self]; + self.isMuted = mute; +} + +-(void) toggleMentionOnly:(BOOL) mentionOnly +{ + if(!self.isMuc || self.isMentionOnly == mentionOnly) + return; + if(mentionOnly) + [[DataLayer sharedInstance] setMucAlertOnMentionOnly:self.contactJid onAccount:self.accountID]; + else + [[DataLayer sharedInstance] setMucAlertOnAll:self.contactJid onAccount:self.accountID]; + self.isMentionOnly = mentionOnly; +} + +-(BOOL) toggleEncryption:(BOOL) encrypt +{ +#ifdef DISABLE_OMEMO + return NO; +#else + xmpp* account = self.account; + if(account == nil || account.omemo == nil) + return NO; + if(self.isMuc == NO) + { + NSSet* knownDevices = [account.omemo knownDevicesForAddressName:self.contactJid]; + DDLogVerbose(@"Current isEncrypted=%@, encrypt=%@, knownDevices=%@", bool2str(self.isEncrypted), bool2str(encrypt), knownDevices); + if(!self.isEncrypted && encrypt && knownDevices.count == 0) + { + // request devicelist again + [account.omemo subscribeAndFetchDevicelistIfNoSessionExistsForJid:self.contactJid]; + return NO; + } + } + else if([self.mucType isEqualToString:kMucTypeGroup] == NO) + { + return NO; + } + if(self.isEncrypted == encrypt) + return YES; + + if(encrypt) + [[DataLayer sharedInstance] encryptForJid:self.contactJid andAccountID:self.accountID]; + else + [[DataLayer sharedInstance] disableEncryptForJid:self.contactJid andAccountID:self.accountID]; + self.isEncrypted = encrypt; + return YES; +#endif +} + +-(void) togglePinnedChat:(BOOL) pinned +{ + if(self.isPinned == pinned) + return; + if(pinned) + [[DataLayer sharedInstance] pinChat:self.accountID andBuddyJid:self.contactJid]; + else + [[DataLayer sharedInstance] unPinChat:self.accountID andBuddyJid:self.contactJid]; + self.isPinned = pinned; + // update active chats + xmpp* account = self.account; + if(account == nil) + return; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{@"contact":self, @"pinningChanged": @YES}]; +} + +-(BOOL) toggleBlocked:(BOOL) block +{ + if(self.isBlocked == block) + return YES; + xmpp* account = self.account; + if(account == nil) + return NO; + if(![account.connectionProperties.serverDiscoFeatures containsObject:@"urn:xmpp:blocking"]) + return NO; + [[MLXMPPManager sharedInstance] block:block contact:self]; + return YES; +} + +-(void) removeFromRoster +{ + [[MLXMPPManager sharedInstance] removeContact:self]; + [self removeShareInteractions]; +} + +-(void) addToRoster +{ + [[MLXMPPManager sharedInstance] addContact:self]; +} + +-(void) clearHistory +{ + [[DataLayer sharedInstance] clearMessagesWithBuddy:self.contactJid onAccount:self.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; +} + +#pragma mark - NSCoding + +-(void) encodeWithCoder:(NSCoder*) coder +{ + [coder encodeObject:self.contactJid forKey:@"contactJid"]; + [coder encodeObject:self.nickName forKey:@"nickName"]; + [coder encodeObject:self.fullName forKey:@"fullName"]; + [coder encodeObject:self.subscription forKey:@"subscription"]; + [coder encodeObject:self.ask forKey:@"ask"]; + [coder encodeObject:self.accountID forKey:@"accountID"]; + [coder encodeObject:self.groupSubject forKey:@"groupSubject"]; + [coder encodeObject:self.accountNickInGroup forKey:@"accountNickInGroup"]; + [coder encodeObject:self.mucType forKey:@"mucType"]; + [coder encodeBool:self.isMuc forKey:@"isMuc"]; + [coder encodeBool:self.isMentionOnly forKey:@"isMentionOnly"]; + [coder encodeBool:self.isPinned forKey:@"isPinned"]; + [coder encodeBool:self.isBlocked forKey:@"isBlocked"]; + [coder encodeObject:self.statusMessage forKey:@"statusMessage"]; + [coder encodeObject:self.state forKey:@"state"]; + [coder encodeInteger:self->_unreadCount forKey:@"unreadCount"]; + [coder encodeBool:self.isActiveChat forKey:@"isActiveChat"]; + [coder encodeBool:self.isEncrypted forKey:@"isEncrypted"]; + [coder encodeBool:self.isMuted forKey:@"isMuted"]; + [coder encodeObject:self.lastInteractionTime forKey:@"lastInteractionTime"]; + [coder encodeObject:self.rosterGroups forKey:@"rosterGroups"]; +} + +-(instancetype) initWithCoder:(NSCoder*) coder +{ + self = [self init]; + self.contactJid = [coder decodeObjectForKey:@"contactJid"]; + self.nickName = [coder decodeObjectForKey:@"nickName"]; + self.fullName = [coder decodeObjectForKey:@"fullName"]; + self.subscription = [coder decodeObjectForKey:@"subscription"]; + self.ask = [coder decodeObjectForKey:@"ask"]; + self.accountID = [coder decodeObjectForKey:@"accountID"]; + self.groupSubject = [coder decodeObjectForKey:@"groupSubject"]; + self.accountNickInGroup = [coder decodeObjectForKey:@"accountNickInGroup"]; + self.mucType = [coder decodeObjectForKey:@"mucType"]; + self.isMuc = [coder decodeBoolForKey:@"isMuc"]; + self.isMentionOnly = [coder decodeBoolForKey:@"isMentionOnly"]; + self.isPinned = [coder decodeBoolForKey:@"isPinned"]; + self.isBlocked = [coder decodeBoolForKey:@"isBlocked"]; + self.statusMessage = [coder decodeObjectForKey:@"statusMessage"]; + self.state = [coder decodeObjectForKey:@"state"]; + self->_unreadCount = [coder decodeIntegerForKey:@"unreadCount"]; + self.isActiveChat = [coder decodeBoolForKey:@"isActiveChat"]; + self.isEncrypted = [coder decodeBoolForKey:@"isEncrypted"]; + self.isMuted = [coder decodeBoolForKey:@"isMuted"]; + self.lastInteractionTime = [coder decodeObjectForKey:@"lastInteractionTime"]; + self.rosterGroups = [coder decodeObjectForKey:@"rosterGroups"]; + return self; +} + +-(void) updateWithContact:(MLContact*) contact +{ + updateIfIdNotEqual(self.contactJid, contact.contactJid); + updateIfIdNotEqual(self.nickName, contact.nickName); + updateIfIdNotEqual(self.fullName, contact.fullName); + updateIfIdNotEqual(self.subscription, contact.subscription); + updateIfIdNotEqual(self.ask, contact.ask); + updateIfIdNotEqual(self.accountID, contact.accountID); + updateIfIdNotEqual(self.groupSubject, contact.groupSubject); + updateIfIdNotEqual(self.accountNickInGroup, contact.accountNickInGroup); + updateIfPrimitiveNotEqual(self.isMuc, contact.isMuc); + if(self.isMuc) + updateIfIdNotEqual(self.mucType, nilDefault(contact.mucType, kMucTypeChannel)); + updateIfPrimitiveNotEqual(self.isMentionOnly, contact.isMentionOnly); + updateIfPrimitiveNotEqual(self.isPinned, contact.isPinned); + updateIfPrimitiveNotEqual(self.isBlocked, contact.isBlocked); + updateIfIdNotEqual(self.statusMessage, contact.statusMessage); + updateIfIdNotEqual(self.state, contact.state); + updateIfPrimitiveNotEqual(self->_unreadCount, contact->_unreadCount); + updateIfPrimitiveNotEqual(self.isActiveChat, contact.isActiveChat); + updateIfPrimitiveNotEqual(self.isEncrypted, contact.isEncrypted); + updateIfPrimitiveNotEqual(self.isMuted, contact.isMuted); + //don't update lastInteractionTime from contact, we dynamically update ourselves by handling kMonalLastInteractionUpdatedNotice + //updateIfIdNotEqual(self.lastInteractionTime, contact.lastInteractionTime); + updateIfIdNotEqual(self.rosterGroups, contact.rosterGroups); +} + +-(BOOL) isEqualToMessage:(MLMessage*) message +{ + return message != nil && + [self.contactJid isEqualToString:message.buddyName] && + self.accountID.intValue == message.accountID.intValue; +} + +-(BOOL) isEqualToContact:(MLContact*) contact +{ + return contact != nil && + [self.contactJid isEqualToString:contact.contactJid] && + self.accountID.intValue == contact.accountID.intValue; +} + +-(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:[MLMessage class]]) + return [self isEqualToMessage:(MLMessage*)object]; + else + return NO; +} + +-(NSUInteger) hash +{ + return [self.contactJid hash] ^ [self.accountID hash]; +} + +-(NSString*) id +{ + return [NSString stringWithFormat:@"%@|%@", self.accountID, self.contactJid]; +} + +-(NSString*) description +{ + return [NSString stringWithFormat:@"%@: %@ (%@) %@%@%@, kSub=%@", self.accountID, self.contactJid, self.isMuc ? self.mucType : @"1:1", self.isInRoster ? @"inRoster" : @"not(inRoster)", self.hasIncomingContactRequest ? @"[incomingContactRequest]" : @"", self.hasOutgoingContactRequest ? @"[outgoingContactRequest]" : @"", self.subscription]; +} + ++(MLContact*) contactFromDictionary:(NSDictionary*) dic +{ + MLContact* contact = [MLContact new]; + contact.contactJid = [dic objectForKey:@"buddy_name"]; + contact.nickName = nilDefault([dic objectForKey:@"nick_name"], @""); + contact.fullName = nilDefault([dic objectForKey:@"full_name"], @""); + contact.subscription = nilDefault([dic objectForKey:@"subscription"], kSubNone); + contact.ask = nilDefault([dic objectForKey:@"ask"], @""); + contact.accountID = [dic objectForKey:@"account_id"]; + contact.groupSubject = nilDefault([dic objectForKey:@"muc_subject"], @""); + contact.accountNickInGroup = nilDefault([dic objectForKey:@"muc_nick"], @""); + contact.mucType = [dic objectForKey:@"muc_type"]; + contact.isMuc = [[dic objectForKey:@"Muc"] boolValue]; + if(contact.isMuc && !contact.mucType) + contact.mucType = kMucTypeChannel; //default value + contact.mucType = nilDefault(contact.mucType, @""); + contact.isMentionOnly = [[dic objectForKey:@"mentionOnly"] boolValue]; + contact.isPinned = [[dic objectForKey:@"pinned"] boolValue]; + contact.statusMessage = nilDefault([dic objectForKey:@"status"], @""); + contact.state = nilDefault([dic objectForKey:@"state"], @"online"); + contact->_unreadCount = -1; + contact.isActiveChat = [[dic objectForKey:@"isActiveChat"] boolValue]; + contact.isEncrypted = [[dic objectForKey:@"encrypt"] boolValue]; + contact.isMuted = [[dic objectForKey:@"muted"] boolValue]; + // initial value comes from db, all other values get updated by our kMonalLastInteractionUpdatedNotice handler + contact.lastInteractionTime = nilExtractor([dic objectForKey:@"lastInteraction"]); //no default needed, already done in DataLayer + contact.rosterGroups = [dic objectForKey:@"rosterGroups"]; + contact->_avatar = nil; + + MLAssert(contact.rosterGroups != nil, @"rosterGroups must be non-nil (if a user is in no groups, it should be empty set)"); + + return contact; +} + +@end diff --git a/Monal/Classes/MLContactCell.h b/Monal/Classes/MLContactCell.h new file mode 100644 index 0000000..aac4bf0 --- /dev/null +++ b/Monal/Classes/MLContactCell.h @@ -0,0 +1,29 @@ +// +// MLContactCell.h +// Monal +// +// Created by Anurodh Pokharel on 7/7/13. +// +// +#import "MLAttributedLabel.h" +#import "MLContact.h" +#import "MLMessage.h" + +@interface MLContactCell : UITableViewCell + +@property (nonatomic, weak) IBOutlet UILabel* _Nullable displayName; +@property (nonatomic, weak) IBOutlet UILabel* _Nullable centeredDisplayName; +@property (nonatomic, weak) IBOutlet UILabel* _Nullable time; + +@property (nonatomic, weak) IBOutlet MLAttributedLabel* _Nullable statusText; +@property (nonatomic, weak) IBOutlet UIImageView* _Nullable userImage; +@property (nonatomic, weak) IBOutlet UIButton* _Nullable badge; +@property (nonatomic, weak) IBOutlet UIImageView* _Nullable muteBadge; +@property (nonatomic, weak) IBOutlet UIImageView* _Nullable mentionBadge; +@property (weak, nonatomic) IBOutlet UIImageView* _Nullable pinBadge; + +@property (nonatomic, assign) BOOL isPinned; + +-(void) initCell:(MLContact* _Nonnull) contact withLastMessage:(MLMessage* _Nullable) lastMessage; + +@end diff --git a/Monal/Classes/MLContactCell.m b/Monal/Classes/MLContactCell.m new file mode 100644 index 0000000..4a83c09 --- /dev/null +++ b/Monal/Classes/MLContactCell.m @@ -0,0 +1,232 @@ +// +// MLContactCell.m +// Monal +// +// Created by Anurodh Pokharel on 7/7/13. +// +// + +#import "MLContactCell.h" +#import "MLConstants.h" +#import "MLContact.h" +#import "MLMessage.h" +#import "DataLayer.h" +#import "MLXEPSlashMeHandler.h" +#import "HelperTools.h" +#import "MLXMPPManager.h" +#import "xmpp.h" +#import "MLImageManager.h" +#import + +@interface MLContactCell() + +@end + +@implementation MLContactCell + +-(void) awakeFromNib +{ + [super awakeFromNib]; +} + +-(void) initCell:(MLContact*) contact withLastMessage:(MLMessage* _Nullable) lastMessage +{ + [self showDisplayName:contact.contactDisplayName]; + [self setPinned:contact.isPinned]; + [self setCount:(long)contact.unreadCount]; + [self displayLastMessage:lastMessage forContact:contact]; + + [[MLImageManager sharedInstance] getIconForContact:contact withCompletion:^(UIImage *image) { + self.userImage.image = image; + }]; + + if(contact.isMuc && contact.isMentionOnly) + { + self.muteBadge.hidden = YES; + self.mentionBadge.hidden = NO; + } + else + { + self.muteBadge.hidden = !contact.isMuted; + self.mentionBadge.hidden = YES; + } +} + +-(void) displayLastMessage:(MLMessage* _Nullable) lastMessage forContact:(MLContact*) contact +{ + NSString* senderOfLastGroupMsg; // set to nick of sender in a group chat, if this is a group chat (1:1 MUST be nil) + if(lastMessage.isMuc) + senderOfLastGroupMsg = lastMessage.contactDisplayName; + + if(lastMessage) + { + if(lastMessage.retracted) + { + NSString* retractedStatus = NSLocalizedString(@"This message got retracted", @""); + [self showStatusTextItalic:retractedStatus withItalicRange:NSMakeRange(0, retractedStatus.length)]; + } + else if([lastMessage.messageType isEqualToString:kMessageTypeUrl] && [[HelperTools defaultsDB] boolForKey:@"ShowURLPreview"]) + [self showStatusText:NSLocalizedString(@"🔗 A Link", @"") inboundDir:lastMessage.inbound fromUser:senderOfLastGroupMsg]; + else if([lastMessage.messageType isEqualToString:kMessageTypeFiletransfer]) + { + if([lastMessage.filetransferMimeType hasPrefix:@"image/"]) + [self showStatusText:NSLocalizedString(@"📷 An Image", @"") inboundDir:lastMessage.inbound fromUser:senderOfLastGroupMsg]; + else if([lastMessage.filetransferMimeType hasPrefix:@"audio/"]) + [self showStatusText:NSLocalizedString(@"🎵 An Audiomessage", @"") inboundDir:lastMessage.inbound fromUser:senderOfLastGroupMsg]; + else if([lastMessage.filetransferMimeType hasPrefix:@"video/"]) + [self showStatusText:NSLocalizedString(@"🎥 A Video", @"") inboundDir:lastMessage.inbound fromUser:senderOfLastGroupMsg]; + else if([lastMessage.filetransferMimeType isEqualToString:@"application/pdf"]) + [self showStatusText:NSLocalizedString(@"📄 A Document", @"") inboundDir:lastMessage.inbound fromUser:senderOfLastGroupMsg]; + else + [self showStatusText:NSLocalizedString(@"📁 A File", @"") inboundDir:lastMessage.inbound fromUser:senderOfLastGroupMsg]; + } + else if ([lastMessage.messageType isEqualToString:kMessageTypeMessageDraft]) + { + NSString* draftPreviewPrefix = NSLocalizedString(@"Draft:", @""); + NSString* draftPreview = [NSString stringWithFormat:@"%@ %@", draftPreviewPrefix, lastMessage.messageText]; + [self showStatusTextItalic:draftPreview withItalicRange:NSMakeRange(0, draftPreviewPrefix.length)]; + } + else if([lastMessage.messageType isEqualToString:kMessageTypeGeo]) + [self showStatusText:NSLocalizedString(@"📍 A Location", @"") inboundDir:lastMessage.inbound fromUser:senderOfLastGroupMsg]; + else + { + if([lastMessage.messageText hasPrefix:@"/me "]) + { + NSString* replacedMessageText = [[MLXEPSlashMeHandler sharedInstance] stringSlashMeWithMessage:lastMessage]; + [self showStatusTextItalic:replacedMessageText withItalicRange:NSMakeRange(0, replacedMessageText.length)]; + } + else + { + [self showStatusText:lastMessage.messageText inboundDir:lastMessage.inbound fromUser:senderOfLastGroupMsg]; + } + } + if(lastMessage.timestamp) + { + self.time.text = [self formattedDateWithSource:lastMessage.timestamp]; + self.time.hidden = NO; + } + else + self.time.hidden = YES; + } + else + [self showStatusText:nil inboundDir:NO fromUser:nil]; +} + +-(void) showStatusText:(NSString *) text inboundDir:(BOOL) inboundDir fromUser:(NSString* _Nullable) fromUser +{ + NSString* statusMessage = @""; + if(inboundDir == NO) + statusMessage = [NSString stringWithFormat:@"%@ ", NSLocalizedString(@"Me:", @"Prefix for own messages in chat overview")]; + else if(inboundDir == YES && fromUser != nil && fromUser.length > 0) + statusMessage = [NSString stringWithFormat:@"%@: ", fromUser]; + + // set range of "Me" prefix that should be gray + NSRange meAttrRange = NSMakeRange(0, statusMessage.length); + + if(text != nil) + { + statusMessage = [statusMessage stringByAppendingString:text]; + // set attribute settings + NSMutableAttributedString* attrStatusText = [[NSMutableAttributedString alloc] initWithString:statusMessage]; + [attrStatusText addAttribute:NSForegroundColorAttributeName value:[UIColor lightGrayColor] range:meAttrRange]; + + if(![attrStatusText isEqualToAttributedString:self.statusText.originalAttributedText]) + { + // only update UI if needed + self.statusText.attributedText = attrStatusText; + [self setStatusTextLayout:text]; + } + } + else + { + self.statusText.text = nil; + } +} + +-(void) showStatusTextItalic:(NSString*) text withItalicRange:(NSRange) italicRange +{ + UIFont* italicFont = [UIFont italicSystemFontOfSize:self.statusText.font.pointSize]; + NSMutableAttributedString* italicString = [[NSMutableAttributedString alloc] initWithString:text]; + [italicString addAttribute:NSFontAttributeName value:italicFont range:italicRange]; + + if(![italicString isEqualToAttributedString:self.statusText.originalAttributedText]) + { + self.statusText.attributedText = italicString; + [self setStatusTextLayout:text]; + } +} + +-(void) setStatusTextLayout:(NSString*) text +{ + if(text) + { + self.centeredDisplayName.hidden = YES; + self.displayName.hidden = NO; + self.statusText.hidden = NO; + } + else + { + self.centeredDisplayName.hidden = NO; + self.displayName.hidden=YES; + self.statusText.hidden=YES; + } +} + +-(void) showDisplayName:(NSString *) name +{ + if(self.displayName && ![self.displayName.text isEqualToString:name]) + { + self.centeredDisplayName.text = name; + self.displayName.text = name; + } +} + +-(void) setCount:(long)count +{ + if(count > 0) + { + // show number of unread messages + [self.badge setTitle:[NSString stringWithFormat:@"%ld", (long)count] forState:UIControlStateNormal]; + self.badge.hidden = NO; + } + else + { + // hide number of unread messages + [self.badge setTitle:@"" forState:UIControlStateNormal]; + self.badge.hidden = YES; + } +} + +-(void) setPinned:(BOOL) pinned +{ + self.isPinned = pinned; + + if(pinned) { + self.pinBadge.hidden = NO; + } else { + self.pinBadge.hidden = YES; + } +} + +#pragma mark - date +-(NSString*) formattedDateWithSource:(NSDate*) sourceDate +{ + NSDateFormatter* dateFormatter = [NSDateFormatter new]; + if([[NSCalendar currentCalendar] isDateInToday:sourceDate]) + { + //today just show time + [dateFormatter setDateStyle:NSDateFormatterNoStyle]; + [dateFormatter setTimeStyle:NSDateFormatterShortStyle]; + } + else + { + // note: if it isnt the same day we want to show the full day + [dateFormatter setDateStyle:NSDateFormatterMediumStyle]; + //no more need for seconds + [dateFormatter setTimeStyle:NSDateFormatterNoStyle]; + } + NSString* dateString = [dateFormatter stringFromDate:sourceDate]; + return dateString ? dateString : @""; +} + +@end diff --git a/Monal/Classes/MLContactCell.xib b/Monal/Classes/MLContactCell.xib new file mode 100644 index 0000000..102a64a --- /dev/null +++ b/Monal/Classes/MLContactCell.xib @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Classes/MLContactSoftwareVersionInfo.h b/Monal/Classes/MLContactSoftwareVersionInfo.h new file mode 100644 index 0000000..dc6eb71 --- /dev/null +++ b/Monal/Classes/MLContactSoftwareVersionInfo.h @@ -0,0 +1,29 @@ +// +// MLContactSofwareVersionInfo.h +// monalxmpp +// +// Created by Friedrich Altheide on 24.12.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MLContactSoftwareVersionInfo : NSObject + +@property (nonatomic, copy) NSString* fromJid; +@property (nonatomic, copy) NSString* _Nullable resource; +@property (nonatomic, copy) NSString* _Nullable appName; +@property (nonatomic, copy) NSString* _Nullable appVersion; +@property (nonatomic, copy) NSString* _Nullable platformOs; +@property (nonatomic, copy) NSDate* _Nullable lastInteraction; + ++(BOOL) supportsSecureCoding; +-(instancetype) initWithJid:(NSString*) jid andRessource:(NSString* _Nullable) resource andAppName:(NSString* _Nullable) appName andAppVersion:(NSString* _Nullable) appVersion andPlatformOS:(NSString* _Nullable) platformOs andLastInteraction:(NSDate* _Nullable) lastInteraction; +-(BOOL) isEqual:(id _Nullable) object; +-(NSUInteger) hash; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLContactSoftwareVersionInfo.m b/Monal/Classes/MLContactSoftwareVersionInfo.m new file mode 100644 index 0000000..234407e --- /dev/null +++ b/Monal/Classes/MLContactSoftwareVersionInfo.m @@ -0,0 +1,78 @@ +// +// MLContactSoftwareVersionInfo.m +// monalxmpp +// +// Created by Friedrich Altheide on 24.12.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import "MLContactSoftwareVersionInfo.h" + +@interface MLContactSoftwareVersionInfo () + +@end + +@implementation MLContactSoftwareVersionInfo + ++(BOOL) supportsSecureCoding +{ + return YES; +} + +-(instancetype) initWithJid:(NSString*) jid andRessource:(NSString* _Nullable) resource andAppName:(NSString* _Nullable) appName andAppVersion:(NSString* _Nullable) appVersion andPlatformOS:(NSString* _Nullable) platformOs andLastInteraction:(NSDate* _Nullable) lastInteraction +{ + self = [super init]; + self.fromJid = jid; + self.resource = resource; + self.appName = appName; + self.appVersion = appVersion; + self.platformOs = platformOs; + self.lastInteraction = lastInteraction; + return self; +} + +-(void) encodeWithCoder:(NSCoder*) coder +{ + [coder encodeObject:self.fromJid forKey:@"fromJid"]; + [coder encodeObject:self.resource forKey:@"resource"]; + [coder encodeObject:self.appName forKey:@"appName"]; + [coder encodeObject:self.appVersion forKey:@"appVersion"]; + [coder encodeObject:self.platformOs forKey:@"platformOs"]; + [coder encodeObject:self.lastInteraction forKey:@"lastInteraction"]; +} + +-(instancetype) initWithCoder:(NSCoder*) coder +{ + self = [self init]; + self.fromJid = [coder decodeObjectForKey:@"fromJid"]; + self.resource = [coder decodeObjectForKey:@"resource"]; + self.appName = [coder decodeObjectForKey:@"appName"]; + self.appVersion = [coder decodeObjectForKey:@"appVersion"]; + self.platformOs = [coder decodeObjectForKey:@"platformOs"]; + self.lastInteraction = [coder decodeObjectForKey:@"lastInteraction"]; + return self; +} + +-(BOOL) isEqual:(id _Nullable) object +{ + if(object == nil || self == object) + return YES; + else if([object isKindOfClass:[MLContactSoftwareVersionInfo class]]) + return [self.fromJid isEqualToString:((MLContactSoftwareVersionInfo*)object).fromJid] && [self.resource isEqualToString:((MLContactSoftwareVersionInfo*)object).resource]; + else + return NO; +} + +-(NSUInteger) hash +{ + return [self.fromJid hash] ^ [self.resource hash] ^ [self.appName hash] ^ [self.appVersion hash] ^ [self.platformOs hash] ^ [self.lastInteraction hash]; +} + +-(NSString*) id +{ + if(self.resource == nil) + return [NSString stringWithFormat:@"%@", self.fromJid]; + return [NSString stringWithFormat:@"%@/%@", self.fromJid, self.resource]; +} + +@end diff --git a/Monal/Classes/MLCrashReporter.h b/Monal/Classes/MLCrashReporter.h new file mode 100644 index 0000000..172fb80 --- /dev/null +++ b/Monal/Classes/MLCrashReporter.h @@ -0,0 +1,18 @@ +// +// MLCrashReporter.h +// Monal +// +// Created by admin on 21.06.23. +// Copyright © 2023 monal-im.org. All rights reserved. +// + +#ifndef MLCrashReporter_h +#define MLCrashReporter_h + +@class UIViewController; + +@interface MLCrashReporter : NSObject ++(void) reportPendingCrashes; +@end + +#endif /* MLCrashReporter_h */ diff --git a/Monal/Classes/MLCrashReporter.m b/Monal/Classes/MLCrashReporter.m new file mode 100644 index 0000000..08437b6 --- /dev/null +++ b/Monal/Classes/MLCrashReporter.m @@ -0,0 +1,386 @@ +// +// MLCrashReporter.m +// Monal +// +// Created by admin on 21.06.23. +// Copyright © 2023 monal-im.org. All rights reserved. +// + +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import "MLConstants.h" +#import "HelperTools.h" +#import "MonalAppDelegate.h" +#import "MLCrashReporter.h" + +#define PART_SEPARATOR_FORMAT "\n\n-------- d049d576-9bf0-47dd-839f-dee6b07c1df9 -------- %@ -------- d049d576-9bf0-47dd-839f-dee6b07c1df9 --------\n\n" + +@interface KSCrashReportFilterAlert: NSObject ++(instancetype) filter; +@end + +@interface KSCrashReportFilterEmpty: NSObject ++(instancetype) filter; +@end + +@interface KSCrashReportFilterAddAuxInfo : NSObject ++(instancetype) filter; +@end + +@interface KSCrashReportFilterAddMLLogfile : NSObject ++(instancetype) filter; +@end + +@interface KSCrashReportFilterAddProfraw : NSObject ++(instancetype) filter; +@end + + +@interface MLCrashReporter() +@property (atomic, strong) NSArray* _Nullable kscrashReports; +@property (atomic, strong) KSCrashReportFilterCompletion _Nullable kscrashCompletion; +@end + + +@implementation MLCrashReporter + ++(void) reportPendingCrashes +{ + //send out pending KSCrash reports + KSCrash* handler = [KSCrash sharedInstance]; + handler.deleteBehaviorAfterSendAll = KSCDeleteAlways; //KSCDeleteNever + id dummyFilter = [KSCrashReportFilterEmpty filter]; + NSString* dummyFilterName = @"dummy not printed"; + id auxInfoFilter = [KSCrashReportFilterAddAuxInfo filter]; + NSString* auxInfoName = @"AUX Info (*.txt)"; + id appleFilter = [KSCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide]; + NSString* appleName = @"Apple Report (*.crash)"; + NSArray>* jsonFilter = @[[KSCrashReportFilterJSONEncode filterWithOptions:KSJSONEncodeOptionPretty], [KSCrashReportFilterDataToString filter]]; + NSString* jsonName = @"JSON Report (*.json)"; + id logfileFilter = [KSCrashReportFilterAddMLLogfile filter]; + NSString* logfileName = @"Logfile (*.rawlog.gz)"; + id profrawFilter = [KSCrashReportFilterAddMLLogfile filter]; + NSString* profrawName = @"Profile (*.profraw)"; + handler.sink = [KSCrashReportFilterPipeline filterWithFilters: + [KSCrashReportFilterAlert filter], + [KSCrashReportFilterCombine filterWithFiltersAndKeys: + dummyFilter, dummyFilterName, //this dummy is needed to make the filter framework print the title of our aux data + auxInfoFilter, auxInfoName, + appleFilter, appleName, + jsonFilter, jsonName, + logfileFilter, logfileName, + profrawFilter, profrawName, + nil + ], + [KSCrashReportFilterConcatenate filterWithSeparatorFmt:@PART_SEPARATOR_FORMAT keys: + dummyFilterName, + auxInfoName, + appleName, + jsonName, + logfileName, + profrawName, + nil + ], + [KSCrashReportFilterStringToData filter], + [KSCrashReportFilterGZipCompress filterWithCompressionLevel:-1], + [[self alloc] init], //add this class as filter to send out all stuff via mail + nil + ]; + DDLogVerbose(@"Trying to send crash reports..."); + [handler sendAllReportsWithCompletion:^(NSArray* reports, BOOL completed, NSError* error){ + if(completed) + DDLogWarn(@"Sent %d reports", (int)[reports count]); + else + DDLogError(@"Failed to send reports: %@", error); + }]; +} + +-(void) filterReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion +{ + if(![MFMailComposeViewController canSendMail]) + { +#if TARGET_OS_SIMULATOR + u_int32_t runid_raw = arc4random(); + NSString* runid = [HelperTools hexadecimalString:[NSData dataWithBytes:&runid_raw length:sizeof(runid_raw)]]; + int i = 1; + for(NSData* report in reports) + if(![report isKindOfClass:[NSData class]]) + DDLogError(@"Report was of unsupported data type %@", [report class]); + else + { + NSString* path = [[HelperTools getContainerURLForPathComponents:@[[NSString stringWithFormat:@"CrashReport-%@-%d.mcrash.gz", runid, i++]]] path]; + DDLogWarn(@"Writing report %d to file: %@", i, path); + [report writeToFile:path atomically:YES]; + } + kscrash_callCompletion(onCompletion, reports, YES, + [NSError errorWithDomain:[[self class] description] + code:0 + description:@"Crashreports written to simulator container..."]); + return; +#else + UIAlertController* alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Email Error", @"Crash report error dialog") + message:NSLocalizedString(@"This device is not configured to send email.", @"Crash report error dialog") + preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* okAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"Crash report error dialog") + style:UIAlertActionStyleDefault + handler:nil]; + [alertController addAction:okAction]; + [[(MonalAppDelegate*)[[UIApplication sharedApplication] delegate] getTopViewController] presentViewController:alertController animated:YES completion:NULL]; + + kscrash_callCompletion(onCompletion, reports, NO, + [NSError errorWithDomain:[[self class] description] + code:0 + description:NSLocalizedString(@"E-Mail not enabled on device", @"Crash report error dialog")]); + return; +#endif + } + + self.kscrashCompletion = onCompletion; + self.kscrashReports = reports; + + DDLogVerbose(@"Preparing MFMailComposeViewController..."); + MFMailComposeViewController* mailController = [[MFMailComposeViewController alloc] init]; + mailController.mailComposeDelegate = self; + [mailController setToRecipients:@[@"crash@monal-im.org"]]; + [mailController setSubject:@"Crash Reports"]; + [mailController setMessageBody:@"> Please fill in your last actions that led to this crash:\n" isHTML:NO]; + int i = 1; + for(NSData* report in reports) + if(![report isKindOfClass:[NSData class]]) + DDLogError(@"Report was of unsupported data type %@", [report class]); + else + { + DDLogVerbose(@"Adding mail attachment..."); + [mailController addAttachmentData:report mimeType:@"binary" fileName:[NSString stringWithFormat:@"CrashReport-%d.mcrash.gz", i++]]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + DDLogVerbose(@"Presenting MFMailComposeViewController..."); + [[(MonalAppDelegate*)[[UIApplication sharedApplication] delegate] getTopViewController] presentViewController:mailController animated:YES completion:nil]; + }); +} + +-(void) mailComposeController:(__unused MFMailComposeViewController*) mailController didFinishWithResult:(MFMailComposeResult) result error:(NSError*) error +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [[(MonalAppDelegate*)[[UIApplication sharedApplication] delegate] getTopViewController] dismissViewControllerAnimated:YES completion:nil]; + + if(self.kscrashCompletion == nil) + { + DDLogError(@"No kscrash completion given!"); + return; + } + + switch(result) + { + case MFMailComposeResultSent: + DDLogInfo(@"Crash report send result: MFMailComposeResultSent"); + kscrash_callCompletion(self.kscrashCompletion, self.kscrashReports, YES, nil); + break; + case MFMailComposeResultSaved: + DDLogInfo(@"Crash report send result: MFMailComposeResultSaved"); + kscrash_callCompletion(self.kscrashCompletion, self.kscrashReports, YES, nil); + break; + case MFMailComposeResultCancelled: + DDLogInfo(@"Crash report send result: MFMailComposeResultCancelled"); + kscrash_callCompletion(self.kscrashCompletion, self.kscrashReports, NO, + [NSError errorWithDomain:[[self class] description] + code:0 + description:@"User cancelled"]); + break; + case MFMailComposeResultFailed: + DDLogInfo(@"Crash report send result: MFMailComposeResultFailed"); + kscrash_callCompletion(self.kscrashCompletion, self.kscrashReports, NO, error); + break; + default: + { + DDLogInfo(@"Crash report send result: unknown"); + kscrash_callCompletion(self.kscrashCompletion, self.kscrashReports, NO, + [NSError errorWithDomain:[[self class] description] + code:0 + description:@"Unknown MFMailComposeResult: %d", result]); + } + } + + self.kscrashCompletion = nil; + self.kscrashReports = nil; + }); +} + +@end + +@implementation KSCrashReportFilterAlert + ++(instancetype) filter +{ + return [[self alloc] init]; +} + +-(void) filterReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion +{ + NSString* title = NSLocalizedString(@"Crash Detected", @"Crash reporting"); + NSString* message = NSLocalizedString(@"The app crashed last time it was launched. Send a crash report? This crash report will contain privacy related data. We will only use it to debug your crash and delete it afterwards!", @"Crash reporting"); + NSString* yesAnswer = NSLocalizedString(@"Sure, send it!", @"Crash reporting"); + NSString* noAnswer = NSLocalizedString(@"No, thanks", @"Crash reporting"); + + DDLogVerbose(@"KSCrashReportFilterAlert started..."); + dispatch_async(dispatch_get_main_queue(), ^{ + UIAlertController* alertController = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* yesAction = [UIAlertAction actionWithTitle:yesAnswer style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction* _Nonnull action) { + kscrash_callCompletion(onCompletion, reports, YES, nil); + }]; + UIAlertAction* noAction = [UIAlertAction actionWithTitle:noAnswer style:UIAlertActionStyleCancel handler:^(__unused UIAlertAction* _Nonnull action) { + kscrash_callCompletion(onCompletion, reports, NO, nil); + }]; + [alertController addAction:yesAction]; + [alertController addAction:noAction]; + [[(MonalAppDelegate*)[[UIApplication sharedApplication] delegate] getTopViewController] presentViewController:alertController animated:YES completion:NULL]; + }); + DDLogVerbose(@"KSCrashReportFilterAlert finished..."); +} + +@end + +@implementation KSCrashReportFilterEmpty + ++(instancetype) filter +{ + return [[self alloc] init]; +} + +-(void) filterReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion +{ + DDLogVerbose(@"KSCrashReportFilterEmpty started..."); + NSMutableArray* filteredReports = [NSMutableArray arrayWithCapacity:[reports count]]; + for(NSUInteger i = 0; i < reports.count; i++) + [filteredReports addObject:@""]; + DDLogVerbose(@"KSCrashReportFilterEmpty finished..."); + kscrash_callCompletion(onCompletion, filteredReports, YES, nil); +} + +@end + +@implementation KSCrashReportFilterAddAuxInfo + ++(instancetype) filter +{ + return [[self alloc] init]; +} + +-(void) filterReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion +{ + DDLogVerbose(@"KSCrashReportFilterAddAuxInfo started..."); + NSMutableArray* filteredReports = [NSMutableArray arrayWithCapacity:[reports count]]; + for(NSDictionary* report in reports) + { + NSMutableString* auxData = [NSMutableString new]; + + //add version of monal reporting this crash + [auxData appendString:[NSString stringWithFormat:@"reporterVersion: %@\n", [HelperTools appBuildVersionInfoFor:MLVersionTypeLog]]]; + + //add user data to aux data + for(NSString* userKey in report[@"user"]) + [auxData appendString:[NSString stringWithFormat:@"%@: %@\n", userKey, report[@"user"][userKey]]]; + + //add crash_info_message and crash_info_message2 to aux data + NSMutableString* crashInfos = [NSMutableString new]; + for(NSDictionary* binaryImage in report[@"binary_images"]) + { + if(binaryImage[@"crash_info_message"] != nil) + [crashInfos appendString:[NSString stringWithFormat:@"message at %@:\n%@\n\n", binaryImage[@"name"], binaryImage[@"crash_info_message"]]]; + if(binaryImage[@"crash_info_message2"] != nil) + [crashInfos appendString:[NSString stringWithFormat:@"message2 at %@:\n%@\n\n", binaryImage[@"name"], binaryImage[@"crash_info_message2"]]]; + if(binaryImage[@"crash_info_signature"] != nil) + [crashInfos appendString:[NSString stringWithFormat:@"signature at %@:\n%@\n\n", binaryImage[@"name"], binaryImage[@"crash_info_signature"]]]; + if(binaryImage[@"crash_info_backtrace"] != nil) + [crashInfos appendString:[NSString stringWithFormat:@"backtrace at %@:\n%@\n\n", binaryImage[@"name"], binaryImage[@"crash_info_backtrace"]]]; + } + if([crashInfos length] > 0) + [auxData appendString:[NSString stringWithFormat:@"\nAvailable crash info messages:\n\n%@", crashInfos]]; + + [filteredReports addObject:auxData]; + } + DDLogVerbose(@"KSCrashReportFilterAddAuxInfo finished..."); + kscrash_callCompletion(onCompletion, filteredReports, YES, nil); +} + +@end + +@implementation KSCrashReportFilterAddMLLogfile + ++(instancetype) filter +{ + return [[self alloc] init]; +} + +-(void) filterReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion +{ + DDLogVerbose(@"KSCrashReportFilterAddMLLogfile started..."); + NSMutableArray* filteredReports = [NSMutableArray arrayWithCapacity:[reports count]]; + for(NSDictionary* report in reports) + { + NSString* logfileCopy = report[@"user"][@"logfileCopy"]; + NSData* logfileData = [NSData new]; + if(logfileCopy != nil) + { + DDLogDebug(@"Adding logfile copy of '%@' from '%@' to crash report...", report[@"user"][@"currentLogfile"], report[@"user"][@"logfileCopy"]); + logfileData = [NSData dataWithContentsOfFile:logfileCopy]; + DDLogVerbose(@"NSData of logfile copy: %@", logfileData); + NSError* error = nil; + [[NSFileManager defaultManager] removeItemAtPath:logfileCopy error:&error]; + if(error != nil) + DDLogError(@"Failed to delete logfileCopy: %@", error); + if(logfileData == nil) + logfileData = [NSData new]; + } + DDLogVerbose(@"Converting logfile data to hex..."); + [filteredReports addObject:[HelperTools hexadecimalString:logfileData]]; + } + DDLogVerbose(@"KSCrashReportFilterAddMLLogfile finished..."); + kscrash_callCompletion(onCompletion, filteredReports, YES, nil); +} + +@end + +@implementation KSCrashReportFilterAddProfraw + ++(instancetype) filter +{ + return [[self alloc] init]; +} + +-(void) filterReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion +{ + DDLogVerbose(@"KSCrashReportFilterAddProfraw started..."); + NSMutableArray* filteredReports = [NSMutableArray arrayWithCapacity:[reports count]]; + for(NSDictionary* report in reports) + { + NSString* profileCopy = report[@"user"][@"profileCopy"]; + NSData* profileData = [NSData new]; + if(profileCopy != nil) + { + DDLogDebug(@"Adding profile copy of '%@' from '%@' to crash report...", report[@"user"][@"currentProfile"], report[@"user"][@"profileCopy"]); + profileData = [NSData dataWithContentsOfFile:profileCopy]; + DDLogVerbose(@"NSData of profile copy: %@", profileData); + NSError* error = nil; + [[NSFileManager defaultManager] removeItemAtPath:profileCopy error:&error]; + if(error != nil) + DDLogError(@"Failed to delete profileCopy: %@", error); + if(profileData == nil) + profileData = [NSData new]; + } + DDLogVerbose(@"Converting profile data to hex..."); + [filteredReports addObject:[HelperTools hexadecimalString:profileData]]; + } + DDLogVerbose(@"KSCrashReportFilterAddProfile finished..."); + kscrash_callCompletion(onCompletion, filteredReports, YES, nil); +} + +@end diff --git a/Monal/Classes/MLCrypto.swift b/Monal/Classes/MLCrypto.swift new file mode 100644 index 0000000..806233c --- /dev/null +++ b/Monal/Classes/MLCrypto.swift @@ -0,0 +1,76 @@ +// +// MLCrypto.swift +// monalxmpp +// +// Created by Anurodh Pokharel on 1/7/20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +import UIKit +import CryptoKit +import ASN1Decoder + +@objcMembers +public class MLCrypto: NSObject { + + public func encryptGCM (key: Data, decryptedContent:Data) -> EncryptedPayload? + { + let gcmKey = SymmetricKey.init(data: key) + + let iv = AES.GCM.Nonce() + + do { + let encrypted = try AES.GCM.seal(decryptedContent, using: gcmKey, nonce: iv) + let encryptedPayload = EncryptedPayload() + let combined = encrypted.combined + let ciphertext = encrypted.ciphertext + + if let combined = combined { + let ivData = combined.subdata(in: 0..<12) + //combined is in the format + //iv+body+auth + let range = 12+ciphertext.count.. Data? + { + return Data(AES.GCM.Nonce()) + } + + public func decryptGCM (key: Data, encryptedContent:Data) -> Data? + { + do { + let sealedBoxToOpen = try AES.GCM.SealedBox(combined: encryptedContent) + let gcmKey = SymmetricKey.init(data: key) + let decryptedData = try AES.GCM.open(sealedBoxToOpen, using: gcmKey) + return decryptedData + } catch { + DDLogWarn("Could not decrypt GCM. Returning nil instead: \(String(describing:error))") + return nil; + } + } + + public func getSignatureAlgo(ofCert certData:Data) -> String? + { + do { + let x509 = try X509Certificate(data:certData) + DDLogVerbose("ASN1 decoded cert: \(x509.description)") + return x509.sigAlgOID + } catch { + DDLogError("ASN1 error: \(error)") + return nil + } + } +} diff --git a/Monal/Classes/MLDNSLookup.h b/Monal/Classes/MLDNSLookup.h new file mode 100644 index 0000000..dfc106d --- /dev/null +++ b/Monal/Classes/MLDNSLookup.h @@ -0,0 +1,61 @@ +// +// MLDNSLookup.h +// Monal +// +// Created by Anurodh Pokharel on 12/4/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import +#import +#import +#import +#import +#import +#import +#import + +#ifndef T_SRV +#define T_SRV kDNSServiceType_SRV +#endif + +#ifndef T_PTR +#define T_PTR kDNSServiceType_PTR +#endif + +#ifndef T_A +#define T_A kDNSServiceType_A +#endif + +#ifndef T_TXT +#define T_TXT kDNSServiceType_TXT +#endif + +#define MAX_DOMAIN_LABEL 63 +#define MAX_DOMAIN_NAME 255 +#define MAX_CSTRING 2044 + + +typedef union { unsigned char b[2]; unsigned short NotAnInteger; } Opaque16; + +typedef struct { u_char c[MAX_DOMAIN_LABEL]; } domainLabel; +typedef struct { u_char c[MAX_DOMAIN_NAME]; } domainName; + + +typedef struct __attribute__((packed)) +{ + uint16_t priority; + uint16_t weight; + uint16_t port; + domainName target; +} srv_rdata; + + +NS_ASSUME_NONNULL_BEGIN + +@interface MLDNSLookup : NSObject +@property (nonatomic, strong) NSMutableArray* discoveredServers; +-(NSArray*) dnsDiscoverOnDomain:(NSString*) domain; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLDNSLookup.m b/Monal/Classes/MLDNSLookup.m new file mode 100644 index 0000000..8116f7a --- /dev/null +++ b/Monal/Classes/MLDNSLookup.m @@ -0,0 +1,298 @@ +// +// MLDNSLookup.m +// Monal +// +// Created by Anurodh Pokharel on 12/4/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" +#import "MLDNSLookup.h" +#import "HelperTools.h" +@import Darwin.POSIX.sys.time; + +@interface MLDNSLookup() +@end + +static NSMutableDictionary* _RRCache; + +@implementation MLDNSLookup + ++(void) initialize +{ + _RRCache = [NSMutableDictionary new]; +} + +-(id) init +{ + self = [super init]; + self.discoveredServers = [NSMutableArray new]; + return self; +} + +-(void) doDiscoveryWithSecure:(BOOL) secure andDomain:(NSString*) domain withTimeout:(NSTimeInterval) query_timeout +{ + DNSServiceRef sdRef; + DNSServiceErrorType res; + + NSTimeInterval remainingTime = query_timeout; + NSDate* startTime = [NSDate date]; + NSDictionary* context = @{ + @"isSecure": secure ? @YES : @NO, + @"caller": self, + }; + NSString* serviceDiscoveryString = [NSString stringWithFormat:@"_xmpp%@-client._tcp.%@", secure ? @"s" : @"", domain]; + res = DNSServiceQueryRecord( + &sdRef, + kDNSServiceFlagsReturnIntermediates, // | kDNSServiceFlagsValidate, + 0, + [serviceDiscoveryString UTF8String], + kDNSServiceType_SRV, + kDNSServiceClass_IN, + query_cb, + (__bridge void*)(context) + ); + if(res == kDNSServiceErr_NoError) + { + int sock = DNSServiceRefSockFD(sdRef); + while (remainingTime > 0) + { + fd_set set; + FD_ZERO(&set); + FD_SET(sock, &set); + + struct timeval tv; + tv.tv_sec = (time_t)remainingTime; + tv.tv_usec = (int32_t)((remainingTime - tv.tv_sec) * 1000000); + + int result = select(FD_SETSIZE, &set, NULL, NULL, &tv); + DDLogVerbose(@"DNS select() returned %d", result); + if(result == 1) + { + if(FD_ISSET(sock, &set)) + { + res = DNSServiceProcessResult(sdRef); + if(res != kDNSServiceErr_NoError) + DDLogError(@"Error %d reading the DNS SRV records for: %@", res, serviceDiscoveryString); + break; + } + } + else if(result == 0) + { + DDLogError(@"DNS SRV select() timed out for: %@", serviceDiscoveryString); + break; + } + else + { + if(errno == EINTR) + { + DDLogInfo(@"DNS SRV select() interrupted, retry for: %@", serviceDiscoveryString); + } + else + { + DDLogError(@"DNS SRV select() returned %d errno %d %s for %@", result, errno, strerror(errno), serviceDiscoveryString); + break; + } + } + + NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; + remainingTime -= elapsed; + } + DNSServiceRefDeallocate(sdRef); + } + else + DDLogError(@"DNS SRV query returned error %d for: %@", res, serviceDiscoveryString); +} + +-(NSArray*) doRealDnsDiscoverOnDomain:(NSString*) domain withTimeout:(NSTimeInterval) timeout +{ + //the whole function is blocking, this synchronized block makes sure we resolve one query at a time (scoped to this class instance) + @synchronized(self) { + @synchronized(self.discoveredServers) { + [self.discoveredServers removeAllObjects]; + } + + //request xmpps and xmpp records, xmpps will be preferred (use a dispatch queue to fetch xmpp and xmpps concurrently) + DDLogVerbose(@"Querying DNS for xmpps AND xmpp records..."); + dispatch_queue_t queue = dispatch_queue_create("im.monal.dnsqueue", DISPATCH_QUEUE_CONCURRENT); + dispatch_async(queue, ^{ + [self doDiscoveryWithSecure:YES andDomain:domain withTimeout:timeout]; + }); + dispatch_async(queue, ^{ + [self doDiscoveryWithSecure:NO andDomain:domain withTimeout:timeout]; + }); + //wait for both dns queries to complete + dispatch_barrier_sync(queue, ^{ + DDLogVerbose(@"SRV DNS queries completed (xmpps AND xmpp)..."); +// [HelperTools flushLogsWithTimeout:0.100]; +// exit(0); + }); + + @synchronized(self.discoveredServers) { + //early return + if([self.discoveredServers count] == 0) + { + DDLogInfo(@"No SRV records could be found, returning empty NSArray..."); + return @[]; + } + + //we ignore weights here for simplicity + [self.discoveredServers sortUsingDescriptors:@[[[NSSortDescriptor alloc] initWithKey:@"priority" ascending:YES]]]; + + //calculate lowest timeout + u_int32_t lowest_ttl = UINT32_MAX; + for(NSDictionary* entry in self.discoveredServers) + { +#ifdef DEBUG + MLAssert([entry isKindOfClass:[NSDictionary class]], @"discoveredServers has an entry that is NOT of type NSDictionary", (@{ + @"entry": entry, + @"discoveredServers": self.discoveredServers, + })); +#endif + if([entry isKindOfClass:[NSDictionary class]]) + lowest_ttl = MIN(lowest_ttl, [entry[@"ttl"] unsignedIntValue]); + } + DDLogVerbose(@"Lowest ttl for SRV records: %u", lowest_ttl); + + //update resource record cache with discovered servers list + DDLogVerbose(@"Updating RRCache with: %@", self.discoveredServers); + @synchronized(_RRCache) { + _RRCache[domain] = @{ + @"timeout": [NSDate dateWithTimeIntervalSinceNow:lowest_ttl], + @"records": [self.discoveredServers copy], + }; + } + + //return discovered servers list + return [self.discoveredServers copy]; + } + } +} + +-(NSArray*) dnsDiscoverOnDomain:(NSString*) domain +{ + /* + @synchronized(_RRCache) { + if(_RRCache[domain] != nil && [_RRCache[domain][@"timeout"] timeIntervalSinceNow] > 0) + { + //update our cache in background + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + [self doRealDnsDiscoverOnDomain:domain withTimeout:16ul]; //long query timeout (this is a background query) + }); + return [_RRCache[domain][@"records"] copy]; + } + } + */ + return [self doRealDnsDiscoverOnDomain:domain withTimeout:8ul]; //short query timeout (we are waiting for this query) +} + + +// ********************************************** C code below ********************************************** + + +char* ConvertDomainLabelToCString_withescape(const domainLabel* label, char* ptr, char esc) +{ + const u_char * src = label->c; // Domain label we're reading + const u_char len = *src++; // Read length of this (non-null) label + const u_char *const end = src + len; // Work out where the label ends + if (len > MAX_DOMAIN_LABEL) return(NULL); // If illegal label, abort + while (src < end) // While we have characters in the label + { + u_char c = *src++; + if (esc) + { + if (c == '.') // If character is a dot, + *ptr++ = esc; // Output escape character + else if (c <= ' ') // If non-printing ascii, + { // Output decimal escape sequence + *ptr++ = esc; + *ptr++ = (char) ('0' + (c / 100) ); + *ptr++ = (char) ('0' + (c / 10) % 10); + c = (u_char)('0' + (c ) % 10); + } + } + *ptr++ = (char)c; // Copy the character + } + *ptr = 0; // Null-terminate the string + return(ptr); // and return +} + +char* ConvertDomainNameToCString_withescape(const domainName* name, int len, char* ptr, char esc) +{ + const u_char *src = name->c; // Domain name we're reading + const u_char *const max = name->c + MIN(MAX_DOMAIN_NAME, len); // Maximum that's valid + + if (*src == 0) *ptr++ = '.'; // Special case: For root, just write a dot + + while (*src) // While more characters in the domain name + { + if (src + 1 + *src >= max) return(NULL); + ptr = ConvertDomainLabelToCString_withescape((const domainLabel *)src, ptr, esc); + if (!ptr) return(NULL); + src += 1 + *src; + *ptr++ = '.'; // Write the dot after the label + } + + *ptr++ = 0; // Null-terminate the string + return(ptr); // and return +} + +void query_cb(const DNSServiceRef DNSServiceRef, const DNSServiceFlags flags, const u_int32_t interfaceIndex, const DNSServiceErrorType errorCode, const char* name __unused, const u_int16_t rrtype, const u_int16_t rrclass, const u_int16_t rdlen, const void* rdata, const u_int32_t ttl, void* _context) +{ + //make sure the compiler doesn't cry because of unused arguments + (void)DNSServiceRef; + (void)interfaceIndex; + (void)rrclass; + + //just ignore errors (don't fill anything into the discoveredServers array) + if(errorCode) + { + DDLogVerbose(@"query callback: error==%d\n", errorCode); + return; + } + + NSDictionary* context = (__bridge NSDictionary*)_context; + BOOL isSecure = [context[@"isSecure"] boolValue]; + MLDNSLookup* caller = (MLDNSLookup*)context[@"caller"]; + + if(rrtype == T_SRV) + { + srv_rdata* srv = (srv_rdata*)rdata; + char targetStr[MAX_CSTRING]; + int srvDomainLen = rdlen - sizeof(srv->priority) - sizeof(srv->weight) - sizeof(srv->port); + if(srvDomainLen > MAX_DOMAIN_NAME) + return; + ConvertDomainNameToCString_withescape(&srv->target, srvDomainLen, targetStr, 0); + DDLogVerbose(@"pri=%d, w=%d, port=%d, target=%s, ttl=%u, flags=%u\n", ntohs(srv->priority), ntohs(srv->weight), ntohs(srv->port), targetStr, ttl, (u_int32_t)flags); + + NSString* theServer = [NSString stringWithUTF8String:targetStr]; + NSNumber* prio = [NSNumber numberWithUnsignedInt:(ntohs(srv->priority) + (isSecure == YES ? 0 : UINT16_MAX))]; // prefer TLS over STARTTLS + NSNumber* weight = [NSNumber numberWithInt:ntohs(srv->weight)]; + NSNumber* thePort = [NSNumber numberWithInt:ntohs(srv->port)]; + if(theServer && prio && weight && thePort) { + // Check if service is not provided (ignored for xmpps records, NOT ignored for xmpp records) + bool serviceEnabled = ![theServer isEqualToString:@"."]; + if(serviceEnabled == false && isSecure == YES) + return; + // Validate that the domain ends with at dot (and ignore this entry, if not) + if([theServer hasSuffix:@"."] == NO) + return; + //add result to discovered severs list + @synchronized(caller.discoveredServers) { + [caller.discoveredServers addObject:@{ + @"priority": prio, + @"server": theServer, + @"port": thePort, + @"isSecure": [NSNumber numberWithBool:isSecure], + @"weight": weight, + @"isEnabled": [NSNumber numberWithBool:serviceEnabled], + @"ttl": [NSNumber numberWithUnsignedInt:ttl], + }]; + } + } + } +} + + +@end diff --git a/Monal/Classes/MLDelayableTimer.h b/Monal/Classes/MLDelayableTimer.h new file mode 100644 index 0000000..2df5949 --- /dev/null +++ b/Monal/Classes/MLDelayableTimer.h @@ -0,0 +1,33 @@ +// +// MLDelayableTimer.h +// monalxmpp +// +// Created by Thilo Molitor on 24.06.24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +#import + +#ifndef MLDelayableTimer_h +#define MLDelayableTimer_h + +NS_ASSUME_NONNULL_BEGIN + +@class MLDelayableTimer; +typedef void (^monal_timer_block_t)(MLDelayableTimer* _Nonnull) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); + +@interface MLDelayableTimer : NSObject + +-(instancetype) initWithHandler:(monal_timer_block_t) handler andCancelHandler:(monal_timer_block_t _Nullable) cancelHandler timeout:(NSTimeInterval) timeout tolerance:(NSTimeInterval) tolerance andDescription:(NSString* _Nullable) description; + +-(void) start; +-(void) trigger; +-(void) pause; +-(void) resume; +-(void) cancel; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MLDelayableTimer_h */ diff --git a/Monal/Classes/MLDelayableTimer.m b/Monal/Classes/MLDelayableTimer.m new file mode 100644 index 0000000..ad7dcee --- /dev/null +++ b/Monal/Classes/MLDelayableTimer.m @@ -0,0 +1,161 @@ +// +// MLDelayableTimer.m +// monalxmpp +// +// Created by Thilo Molitor on 24.06.24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +#import "MLConstants.h" +#import "HelperTools.h" +#import "MLDelayableTimer.h" + +@interface MLDelayableTimer() +{ + NSTimer* _wrappedTimer; + monal_timer_block_t _Nullable _cancelHandler; + NSString* _Nullable _description; + NSTimeInterval _timeout; + NSTimeInterval _remainingTime; + NSUUID* _uuid; +} +@end + +@implementation MLDelayableTimer + +-(instancetype) initWithHandler:(monal_timer_block_t) handler andCancelHandler:(monal_timer_block_t _Nullable) cancelHandler timeout:(NSTimeInterval) timeout tolerance:(NSTimeInterval) tolerance andDescription:(NSString* _Nullable) description +{ + self = [super init]; + _wrappedTimer = [NSTimer timerWithTimeInterval:timeout repeats:NO block:^(NSTimer* _) { + handler(self); + }]; + _cancelHandler = cancelHandler; + _timeout = timeout; + _wrappedTimer.tolerance = tolerance; + _description = description; + _remainingTime = 0; + _uuid = [NSUUID UUID]; + return self; +} + +-(NSString*) description +{ + return [NSString stringWithFormat:@"%@(%G|%G) %@", [_uuid UUIDString], _timeout, _wrappedTimer.fireDate.timeIntervalSinceNow, _description]; +} + +-(void) start +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + showErrorOnAlpha(nil, @"Could not start already fired timer: %@", self); + return; + } + DDLogDebug(@"Starting timer: %@", self); + //scheduling and unscheduling of a timer must be done from the same thread --> use our runloop + [self scheduleBlockInRunLoop:^{ + [[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierTimer] addTimer:self->_wrappedTimer forMode:NSRunLoopCommonModes]; + }]; + } +} + +-(void) trigger +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + showErrorOnAlpha(nil, @"Could not trigger already fired timer: %@", self); + return; + } + DDLogDebug(@"Triggering timer: %@", self); + [_wrappedTimer fire]; + } +} + +-(void) pause +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + DDLogWarn(@"Tried to pause already fired timer: %@", self); + return; + } + NSTimeInterval remaining = _wrappedTimer.fireDate.timeIntervalSinceNow; + if(remaining == 0) + { + DDLogWarn(@"Tried to pause timer the exact second its firing: %@", self); + return; + } + DDLogDebug(@"Pausing timer: %@", self); + _wrappedTimer.fireDate = NSDate.distantFuture; //postpone timer virtually indefinitely + _remainingTime = remaining; + } +} + +-(void) resume +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + DDLogWarn(@"Tried to resume already fired timer: %@", self); + return; + } + if(_remainingTime == 0) + { + DDLogWarn(@"Tried to resume non-paused timer: %@", self); + return; + } + DDLogDebug(@"Resuming timer: %@", self); + _wrappedTimer.fireDate = [NSDate dateWithTimeIntervalSinceNow:_remainingTime]; + _remainingTime = 0; + } +} + +-(void) cancel +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + DDLogWarn(@"Tried to cancel already fired timer: %@", self); + return; + } + DDLogDebug(@"Canceling timer: %@", self); + [self invalidate]; + } + _cancelHandler(self); +} + +-(void) invalidate +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + DDLogWarn(@"Could not invalidate already invalid timer: %@", self); + return; + } + //DDLogVerbose(@"Invalidating timer: %@", self); + //scheduling and unscheduling of a timer must be done from the same thread --> use our runloop + [self scheduleBlockInRunLoop:^{ + [self->_wrappedTimer invalidate]; + }]; + } +} + +-(void) scheduleBlockInRunLoop:(monal_void_block_t) block +{ + NSRunLoop* runLoop = [HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierTimer]; +// NSCondition* condition = [NSCondition new]; +// [condition lock]; + CFRunLoopPerformBlock([runLoop getCFRunLoop], (__bridge CFStringRef)NSDefaultRunLoopMode, ^{ + block(); +// [condition lock]; +// [condition signal]; +// [condition unlock]; + }); + CFRunLoopWakeUp([runLoop getCFRunLoop]); //trigger wakeup of runloop to execute the block as soon as possible +// //wait for our block to finish executing +// [condition wait]; +// [condition unlock]; +} + +@end diff --git a/Monal/Classes/MLEmoji.swift b/Monal/Classes/MLEmoji.swift new file mode 100644 index 0000000..e47b5b9 --- /dev/null +++ b/Monal/Classes/MLEmoji.swift @@ -0,0 +1,27 @@ +// +// MLEmoji.swift +// monalxmpp +// +// Created by Anurodh Pokharel on 3/29/21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +import Foundation + +@objcMembers +public class MLEmoji: NSObject +{ + public static func containsEmoji(text:String) -> Bool + { + for scalar in text.unicodeScalars + { + let isEmoji = scalar.properties.isEmoji && scalar.properties.isEmojiPresentation + + if(!isEmoji) + { + return false + } + } + return true + } +} diff --git a/Monal/Classes/MLEncryptedPayload.h b/Monal/Classes/MLEncryptedPayload.h new file mode 100644 index 0000000..9a1fcc7 --- /dev/null +++ b/Monal/Classes/MLEncryptedPayload.h @@ -0,0 +1,24 @@ +// +// MLEncryptedPayload.h +// Monal +// +// Created by Anurodh Pokharel on 4/19/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MLEncryptedPayload : NSObject +@property (nonatomic, strong, readonly) NSData* body; // the acully encrytped content +@property (nonatomic, strong, readonly) NSData* key; //key + tag as needed by OMEMO +@property (nonatomic, strong, readonly) NSData* iv; +@property (nonatomic, strong, readonly) NSData* authTag; //just tag + +-(MLEncryptedPayload *) initWithBody:(NSData *) body key:(NSData *) key iv:(NSData *) iv authTag:(NSData *) authTag; +-(MLEncryptedPayload *) initWithKey:(NSData *) key iv:(NSData *) iv; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLEncryptedPayload.m b/Monal/Classes/MLEncryptedPayload.m new file mode 100644 index 0000000..685a89b --- /dev/null +++ b/Monal/Classes/MLEncryptedPayload.m @@ -0,0 +1,49 @@ +// +// MLEncryptedPayload.m +// Monal +// +// Created by Anurodh Pokharel on 4/19/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLEncryptedPayload.h" +#import "HelperTools.h" + +@interface MLEncryptedPayload () +@property (nonatomic, strong) NSData* body; +@property (nonatomic, strong) NSData* key; +@property (nonatomic, strong) NSData* iv; +@property (nonatomic, strong) NSData* authTag; +@end + +@implementation MLEncryptedPayload + +-(MLEncryptedPayload *) initWithBody:(NSData *) body key:(NSData *) key iv:(NSData *) iv authTag:(NSData *) authTag +{ + MLAssert(body != nil, @"body must not be nil"); + MLAssert(key != nil, @"key must not be nil"); + MLAssert(iv != nil, @"iv must not be nil"); + MLAssert(authTag != nil, @"authTag must not be nil"); + + self = [super init]; + self.body = body; + self.key = key; + self.iv = iv; + self.authTag = authTag; + return self; +} + +-(MLEncryptedPayload *) initWithKey:(NSData *) key iv:(NSData *) iv +{ + MLAssert(key != nil, @"key must not be nil"); + MLAssert(iv != nil, @"iv must not be nil"); + + self = [super init]; + self.body = nil; + self.key = key; + self.iv = iv; + self.authTag = nil; + return self; +} + +@end diff --git a/Monal/Classes/MLFileTransferDataCell.h b/Monal/Classes/MLFileTransferDataCell.h new file mode 100644 index 0000000..663983c --- /dev/null +++ b/Monal/Classes/MLFileTransferDataCell.h @@ -0,0 +1,34 @@ +// +// MLFileTransferDataCell.h +// Monal +// +// Created by Jim Tsai(poormusic2001@gmail.com) on 2020/12/7. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLBaseCell.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, transferFileState) { + transferCheck = 0, + transferFileTypeNeedDowndload, +}; + +@interface MLFileTransferDataCell : MLBaseCell + +@property (weak, nonatomic) IBOutlet UIView* fileTransferBackgroundView; +@property (weak, nonatomic) IBOutlet UIView* fileTransferBoarderView; +@property (weak, nonatomic) IBOutlet UILabel* fileTransferHint; +@property (weak, nonatomic) IBOutlet UILabel* sizeLabel; +@property (weak, nonatomic) IBOutlet UIImageView* downloadImageView; +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView* loadingView; +@property (nonatomic, copy) NSNumber* messageDBId; +@property (nonatomic) transferFileState transferStatus; + +-(void) initCellForMessageId:(NSNumber*) messageId andFilename:(NSString*) filename andMimeType:(NSString* _Nullable) mimeType andFileSize:(long long) fileSize; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLFileTransferDataCell.m b/Monal/Classes/MLFileTransferDataCell.m new file mode 100644 index 0000000..60866e2 --- /dev/null +++ b/Monal/Classes/MLFileTransferDataCell.m @@ -0,0 +1,139 @@ +// +// MLFileTransferDataCell.m +// Monal +// +// Created by Jim Tsai(poormusic2001@gmail.com) on 2020/12/7. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLFileTransferDataCell.h" +#import "MLImageManager.h" +#import "MLConstants.h" +#import "MLFiletransfer.h" +#import "HelperTools.h" + +@implementation MLFileTransferDataCell + +-(void)awakeFromNib +{ + [super awakeFromNib]; + + // Initialization code + + self.fileTransferBackgroundView.layer.cornerRadius = 5.0f; + self.fileTransferBackgroundView.layer.masksToBounds = YES; + + self.fileTransferBoarderView.layer.cornerRadius = 5.0f; + self.fileTransferBoarderView.layer.borderWidth = 1.3f; + self.fileTransferBoarderView.layer.borderColor = [UIColor colorWithRed:76.0/255.0 green:155.0/255.0 blue:223.0/255.0 alpha:1.0].CGColor; + + [self.loadingView setHidden:YES]; + [self.downloadImageView setHidden:NO]; + [self.sizeLabel setText:@""]; +} + +-(void)layoutSubviews +{ + if([MLFiletransfer isFileForHistoryIdInTransfer:self.messageDBId]) + { + [self.loadingView setHidden:NO]; + [self.loadingView startAnimating]; + [self.downloadImageView setHidden:YES]; + } + else + { + [self.loadingView setHidden:YES]; + [self.loadingView stopAnimating]; + [self.downloadImageView setHidden:NO]; + } +} + +-(void) initCellForMessageId:(NSNumber*) messageId andFilename:(NSString*) filename andMimeType:(NSString* _Nullable) mimeType andFileSize:(long long) fileSize +{ + self.messageDBId = messageId; + // files without a mime type should be checked before download + self.transferStatus = mimeType ? transferFileTypeNeedDowndload : transferCheck; + + NSString* hintStr; + if(mimeType != nil) + { + hintStr = [NSString stringWithFormat:@"%@ %@ (%@).", NSLocalizedString(@"Download", @""), filename, mimeType]; + + NSString* readableFileSize = [NSByteCountFormatter stringFromByteCount:fileSize countStyle:NSByteCountFormatterCountStyleFile]; + [self.sizeLabel setText:readableFileSize]; + + } + else + { + hintStr = [NSString stringWithFormat:@"%@%@", NSLocalizedString(@"Check type and size on ", @""), filename]; + [self.sizeLabel setText:@""]; + } + [self.fileTransferHint setText:hintStr]; + + [self.loadingView setHidden:YES]; + [self.downloadImageView setHidden:NO]; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(void)copy:(id)sender { + UIPasteboard* pboard = [UIPasteboard generalPasteboard]; + pboard.string = self.messageBody.text; +} + +-(void)prepareForReuse +{ + [super prepareForReuse]; + self.messageBody.text = @""; +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated +{ + [super setSelected:selected animated:animated]; + + // Configure the view for the selected state +} + +- (void)doUIActions:(NSNotification*)notification +{ + dispatch_async(dispatch_get_main_queue(), ^{ + + [self.downloadImageView setHidden:NO]; + [self.loadingView stopAnimating]; + [self.loadingView setHidden:YES]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMonalMessageFiletransferUpdateNotice object:nil]; + }); +} + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + + UITouch* touch = [touches anyObject]; + CGPoint touchPoint = [touch locationInView:touch.view]; + + CGPoint insidePoint = [self.fileTransferBackgroundView convertPoint:touchPoint fromView:touch.view]; + if ([self.fileTransferBackgroundView pointInside:insidePoint withEvent:nil]) { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(doUIActions:) name:kMonalMessageFiletransferUpdateNotice object:nil]; + + [self.loadingView setHidden:NO]; + [self.loadingView startAnimating]; + [self.downloadImageView setHidden:YES]; + + switch (self.transferStatus) { + case transferCheck: + [MLFiletransfer checkMimeTypeAndSizeForHistoryID: self.messageDBId]; + break; + case transferFileTypeNeedDowndload: + [MLFiletransfer downloadFileForHistoryID:self.messageDBId]; + break; + default: + unreachable(); + break; + } + } +} + +@end diff --git a/Monal/Classes/MLFileTransferFileViewController.h b/Monal/Classes/MLFileTransferFileViewController.h new file mode 100644 index 0000000..eafc59d --- /dev/null +++ b/Monal/Classes/MLFileTransferFileViewController.h @@ -0,0 +1,23 @@ +// +// MLFileTransferFileViewController.h +// Monal +// +// Created by Jim Tsai(poormusic2001@gmail.com) on 2020/12/28. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MLFileTransferFileViewController : QLPreviewController +@property (nonatomic) NSString *fileUrlStr; +@property (nonatomic) NSString *mimeType; +@property (nonatomic) NSString *fileName; +@property (nonatomic) NSString *fileEncodeName; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLFileTransferFileViewController.m b/Monal/Classes/MLFileTransferFileViewController.m new file mode 100644 index 0000000..b0568b6 --- /dev/null +++ b/Monal/Classes/MLFileTransferFileViewController.m @@ -0,0 +1,85 @@ +// +// MLFileTransferFileViewController.m +// Monal +// +// Created by Jim Tsai(poormusic2001@gmail.com) on 2020/12/28. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLFileTransferFileViewController.h" +#import "MBProgressHUD.h" +#import + +@interface MLFilePreviewItem : NSObject +@property(nullable, nonatomic) NSURL *previewItemURL; +@property(nullable, nonatomic) NSString *previewItemTitle; +@end +@implementation MLFilePreviewItem +- (instancetype)initWithPreviewURL:(NSURL *)fileURL andTitle:(NSString *)title { + self = [super init]; + if (self) { + _previewItemURL = [fileURL copy]; + _previewItemTitle = [title copy]; + } + return self; +} +@end + + +@interface MLFileTransferFileViewController () +@property (nonatomic, strong) MBProgressHUD *loadingHUD; +@property (nonatomic, strong) NSURL *fileUrl; +@property (nonatomic, strong) NSString *fileLink; +@property (nonatomic, strong) NSURL *fileToUrl; +@property (nonatomic, strong) NSFileManager *fileManager; + +@end + + +@implementation MLFileTransferFileViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.loadingHUD = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + self.loadingHUD.label.text = NSLocalizedString(@"Loading Data", @""); + self.loadingHUD.mode = MBProgressHUDModeIndeterminate; + self.loadingHUD.removeFromSuperViewOnHide = YES; + + self.fileManager = [NSFileManager defaultManager]; + + self.dataSource = self; + self.delegate = self; + self.fileUrl = [NSURL fileURLWithPath:self.fileUrlStr]; +} + +- (NSInteger)numberOfPreviewItemsInPreviewController:(nonnull QLPreviewController *)controller { + return 1; +} + +- (nonnull id)previewController:(nonnull QLPreviewController *)controller previewItemAtIndex:(NSInteger)index { + [self.loadingHUD setHidden:YES]; + + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString *documentsDirectory = [paths objectAtIndex:0]; + NSString *filePosition = [documentsDirectory stringByAppendingString:[NSString stringWithFormat:@"/%@",self.fileName]]; + + NSError *fileError = nil; + self.fileToUrl = [NSURL fileURLWithPath:filePosition]; + + [self.fileManager copyItemAtURL:self.fileUrl toURL:self.fileToUrl error:&fileError]; + return [[MLFilePreviewItem alloc] initWithPreviewURL:self.fileToUrl andTitle:filePosition.lastPathComponent]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:NO]; + + NSError *fileError = nil; + [self.fileManager removeItemAtURL:self.fileToUrl error:&fileError]; +} + +@end + + + diff --git a/Monal/Classes/MLFileTransferTextCell.h b/Monal/Classes/MLFileTransferTextCell.h new file mode 100644 index 0000000..f92649e --- /dev/null +++ b/Monal/Classes/MLFileTransferTextCell.h @@ -0,0 +1,32 @@ +// +// MLFileTransferTextCell.h +// Monal +// +// Created by Jim Tsai(poormusic2001@gmail.com) on 2020/12/25. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLBaseCell.h" + +@protocol OpenFileDelegate + +- (void)showData:(NSString*_Nonnull) fileUrlStr withMimeType:(NSString*_Nonnull) mimeType andFileName:(NSString*_Nonnull) fileName andFileEncodeName:(NSString*_Nonnull) encodeName; + +@end + +NS_ASSUME_NONNULL_BEGIN + +@interface MLFileTransferTextCell : MLBaseCell +@property (weak, nonatomic) IBOutlet UIView *fileTransferBackgroundView; +@property (weak, nonatomic) IBOutlet UIView *fileTransferBoarderView; +@property (weak, nonatomic) IBOutlet UILabel *fileTransferHint; +@property (weak, nonatomic) IBOutlet UILabel *sizeLabel; +@property (weak, nonatomic) IBOutlet UIImageView *textFileImageView; +@property (weak, nonatomic) id openFileDelegate; +@property (nonatomic) NSString *fileCacheUrlStr; +@property (nonatomic) NSString *fileMimeType; +@property (nonatomic) NSString *fileName; +@property (nonatomic) NSString *fileEncodeName; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLFileTransferTextCell.m b/Monal/Classes/MLFileTransferTextCell.m new file mode 100644 index 0000000..c06fcd7 --- /dev/null +++ b/Monal/Classes/MLFileTransferTextCell.m @@ -0,0 +1,42 @@ +// +// MLFileTransferTextCell.m +// Monal +// +// Created by Jim Tsai(poormusic2001@gmail.com) on 2020/12/25. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLFileTransferTextCell.h" + +@implementation MLFileTransferTextCell + +- (void)awakeFromNib { + [super awakeFromNib]; + // Initialization code + + // Initialization code + self.fileTransferBackgroundView.layer.cornerRadius = 5.0f; + self.fileTransferBackgroundView.layer.masksToBounds = YES; + + self.fileTransferBoarderView.layer.cornerRadius = 5.0f; + self.fileTransferBoarderView.layer.borderWidth = 1.3f; + self.fileTransferBoarderView.layer.borderColor = [UIColor colorWithRed:76.0/255.0 green:155.0/255.0 blue:223.0/255.0 alpha:1.0].CGColor; +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated { + [super setSelected:selected animated:animated]; + + // Configure the view for the selected state +} + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + UITouch *touch = [touches anyObject]; + CGPoint touchPoint = [touch locationInView:touch.view]; + + CGPoint insidePoint = [self.fileTransferBackgroundView convertPoint:touchPoint fromView:touch.view]; + if ([self.fileTransferBackgroundView pointInside:insidePoint withEvent:nil]) { + [self.openFileDelegate showData:self.fileCacheUrlStr withMimeType:self.fileMimeType andFileName:self.fileName andFileEncodeName:self.fileEncodeName]; + } +} + +@end diff --git a/Monal/Classes/MLFileTransferVideoCell.h b/Monal/Classes/MLFileTransferVideoCell.h new file mode 100644 index 0000000..e54b75a --- /dev/null +++ b/Monal/Classes/MLFileTransferVideoCell.h @@ -0,0 +1,22 @@ +// +// MLFileTransferVideoCell.h +// Monal +// +// Created by Jim Tsai(poormusic2001@gmail.com) on 2020/12/23. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLBaseCell.h" +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MLFileTransferVideoCell : MLBaseCell + +@property (weak, nonatomic) IBOutlet UIView *videoView; + +-(void)avplayerConfigWithUrlStr:(NSString*)fileUrl andMimeType:(NSString*) mimeType fileName:(NSString*) fileName andVC:(UIViewController*) vc; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLFileTransferVideoCell.m b/Monal/Classes/MLFileTransferVideoCell.m new file mode 100644 index 0000000..164f3ca --- /dev/null +++ b/Monal/Classes/MLFileTransferVideoCell.m @@ -0,0 +1,89 @@ +// +// MLFileTransferVideoCell.m +// Monal +// +// Created by Jim Tsai(poormusic2001@gmail.com) on 2020/12/23. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLFileTransferVideoCell.h" +#import "HelperTools.h" + +@implementation MLFileTransferVideoCell + +AVPlayerViewController *avplayerVC; +AVPlayer *avplayer; + +- (void)awakeFromNib { + [super awakeFromNib]; + + // Initialization code + self.videoView.layer.cornerRadius = 5.0f; + self.videoView.layer.masksToBounds = YES; + [self avplayerVCInit]; +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated { + [super setSelected:selected animated:animated]; + + // Configure the view for the selected state +} + +-(void) avplayerVCInit +{ + avplayerVC = [AVPlayerViewController new]; + avplayerVC.showsPlaybackControls = YES; +#if TARGET_OS_MACCATALYST + avplayerVC.allowsPictureInPicturePlayback = NO; +#else + avplayerVC.allowsPictureInPicturePlayback = YES; +#endif + avplayerVC.view.frame = CGRectMake(0, 0, self.videoView.frame.size.width, self.videoView.frame.size.height); + avplayerVC.videoGravity = AVLayerVideoGravityResizeAspect; +} + +-(void) avplayerConfigWithUrlStr:(NSString*)fileUrlStr andMimeType:(NSString*) mimeType fileName:(NSString*) fileName andVC:(UIViewController*) vc{ + for (UIView* subView in self.videoView.subviews) + { + [subView removeFromSuperview]; + } + [self avplayerVCInit]; + + NSURL* videoFileUrl = [[NSURL alloc] initFileURLWithPath:fileUrlStr isDirectory:NO]; + if(videoFileUrl == nil) + { + DDLogWarn(@"Returning early in av player cell!"); + return; + } + + //some translations needed + if([@"audio/mpeg" isEqualToString:mimeType]) + { + DDLogDebug(@"Overwriting mimeType: '%@' with 'audio/mp4'...", mimeType); + mimeType = @"audio/mp4"; + } + + __block AVURLAsset* videoAsset = nil; + //the completion is calles synchronously + [HelperTools createAVURLAssetFromFile:fileUrlStr havingMimeType:mimeType andFileExtension:nil withCompletionHandler:^(AVURLAsset* asset) { + videoAsset = asset; + }]; + if(videoAsset == nil) + { + DDLogWarn(@"Could not create AVURLAsset for video cell!"); + return; + } + + avplayer = [AVPlayer playerWithPlayerItem:[AVPlayerItem playerItemWithAsset:videoAsset]]; + DDLogInfo(@"Created AVPlayer(%@): %@", mimeType, avplayer); + avplayerVC.player = avplayer; + + [self.videoView addSubview:avplayerVC.view]; + [vc addChildViewController:avplayerVC]; + [avplayerVC didMoveToParentViewController:vc]; +} + +-(void)prepareForReuse{ + [super prepareForReuse]; +} +@end diff --git a/Monal/Classes/MLFiletransfer.h b/Monal/Classes/MLFiletransfer.h new file mode 100644 index 0000000..391a6b2 --- /dev/null +++ b/Monal/Classes/MLFiletransfer.h @@ -0,0 +1,35 @@ +// +// MLFiletransfer.h +// monalxmpp +// +// Created by Thilo Molitor on 12.11.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +NS_ASSUME_NONNULL_BEGIN + +@class MLMessage; +@class xmpp; + +@interface MLFiletransfer : NSObject +@property (class, readonly) BOOL isIdle; + ++(BOOL) isIdle; ++(void) doStartupCleanup; ++(void) checkMimeTypeAndSizeForHistoryID:(NSNumber*) historyId; ++(void) downloadFileForHistoryID:(NSNumber*) historyId; ++(NSDictionary* _Nullable) getFileInfoForMessage:(MLMessage* _Nullable) msg; ++(void) deleteFileForMessage:(MLMessage* _Nullable) msg; ++(MLHandler*) prepareDataUpload:(NSData*) data; ++(MLHandler*) prepareDataUpload:(NSData*) data withFileExtension:(NSString*) fileExtension; ++(MLHandler*) prepareFileUpload:(NSURL*) fileUrl; ++(MLHandler*) prepareUIImageUpload:(UIImage*) image; ++(void) uploadFile:(NSURL*) fileUrl onAccount:(xmpp*) account withEncryption:(BOOL) encrypted andCompletion:(void (^)(NSString* _Nullable url, NSString* _Nullable mimeType, NSNumber* _Nullable size, NSError* _Nullable error)) completion; ++(void) uploadUIImage:(UIImage*) image onAccount:(xmpp*) account withEncryption:(BOOL) encrypted andCompletion:(void (^)(NSString* _Nullable url, NSString* _Nullable mimeType, NSNumber* _Nullable size, NSError* _Nullable error)) completion; ++(void) hardlinkFileForMessage:(MLMessage*) msg; ++(BOOL) isFileForHistoryIdInTransfer:(NSNumber*) historyId; ++(NSString*) getMimeTypeOfOriginalFile:(NSString*) file; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLFiletransfer.m b/Monal/Classes/MLFiletransfer.m new file mode 100644 index 0000000..0e0d4d0 --- /dev/null +++ b/Monal/Classes/MLFiletransfer.m @@ -0,0 +1,981 @@ +// +// MLFiletransfer.m +// monalxmpp +// +// Created by Thilo Molitor on 12.11.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" +#import "MLFiletransfer.h" +#import "DataLayer.h" +#import "MLEncryptedPayload.h" +#import "xmpp.h" +#import "AESGcm.h" +#import "MLXMPPManager.h" +#import "MLNotificationQueue.h" + +@import MobileCoreServices; +@import UniformTypeIdentifiers; +@import UIKit.UIImage; + +static NSFileManager* _fileManager; +static NSString* _documentCacheDir; +static NSMutableSet* _currentlyTransfering; +static NSMutableDictionary* _expectedDownloadSizes; +static NSObject* _hardlinkingSyncObject; + +@implementation MLFiletransfer + ++(void) initialize +{ + NSError* error; + _hardlinkingSyncObject = [NSObject new]; + _fileManager = [NSFileManager defaultManager]; + _documentCacheDir = [[HelperTools getContainerURLForPathComponents:@[@"documentCache"]] path]; + + [_fileManager createDirectoryAtURL:[NSURL fileURLWithPath:_documentCacheDir] withIntermediateDirectories:YES attributes:nil error:&error]; + if(error) + @throw [NSException exceptionWithName:@"NSError" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}]; + [HelperTools configureFileProtectionFor:_documentCacheDir]; + + _currentlyTransfering = [NSMutableSet new]; + _expectedDownloadSizes = [NSMutableDictionary new]; +} + ++(BOOL) isIdle +{ + @synchronized(_currentlyTransfering) { + return [_currentlyTransfering count] == 0; + } +} + ++(void) checkMimeTypeAndSizeForHistoryID:(NSNumber*) historyId +{ + NSString* url; + MLMessage* msg = [[DataLayer sharedInstance] messageForHistoryID:historyId]; + if(!msg) + { + DDLogError(@"historyId %@ does not yield an MLMessage object, aborting", historyId); + return; + } + url = [self genCanonicalUrl:msg.messageText]; + @synchronized(_expectedDownloadSizes) { + if(_expectedDownloadSizes[url] == nil) + _expectedDownloadSizes[url] = msg.filetransferSize; + } + //make sure we don't check or download this twice + @synchronized(_currentlyTransfering) { + if([self isFileForHistoryIdInTransfer:historyId]) + { + DDLogDebug(@"Already checking/downloading this content, ignoring"); + return; + } + [_currentlyTransfering addObject:historyId]; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + DDLogInfo(@"Requesting mime-type and size for historyID %@ from http server", historyId); + NSMutableURLRequest* request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:url]]; + if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) + request.requiresDNSSECValidation = YES; + request.HTTPMethod = @"HEAD"; + request.cachePolicy = NSURLRequestReturnCacheDataElseLoad; + + NSURLSession* session = [HelperTools createEphemeralURLSession]; + [[session dataTaskWithRequest:request completionHandler:^(NSData* _Nullable data __unused, NSURLResponse* _Nullable response, NSError* _Nullable error) { + if(error != nil) + { + DDLogError(@"Failed to fetch headers of %@ at %@: %@", msg, url, error); + //check done, remove from "currently checking/downloading list" and set error + [self setErrorType:NSLocalizedString(@"Download error", @"") andErrorText:[NSString stringWithFormat:NSLocalizedString(@"Failed to fetch download metadata: %@", @""), error] forMessage:msg]; + [self markAsComplete:historyId]; + return; + } + NSDictionary* headers = ((NSHTTPURLResponse*)response).allHeaderFields; + NSString* mimeType = [[headers objectForKey:@"Content-Type"] lowercaseString]; + NSNumber* contentLength = [headers objectForKey:@"Content-Length"] ? [NSNumber numberWithInt:([[headers objectForKey:@"Content-Length"] intValue])] : @(-1); + if(!mimeType) //default mime type if none was returned by http server + mimeType = @"application/octet-stream"; + + //try to deduce the content type from a given file extension if needed and possible + if([mimeType isEqualToString:@"application/octet-stream"]) + { + NSURLComponents* urlComponents = [NSURLComponents componentsWithString:url]; + if(urlComponents) + mimeType = [self getMimeTypeOfOriginalFile:urlComponents.path]; + } + + //make sure we *always* have a mime type + if(!mimeType) + mimeType = @"application/octet-stream"; + + DDLogInfo(@"Got http mime-type and size for historyID %@: %@ (%@)", historyId, mimeType, contentLength); + DDLogDebug(@"Updating db and sending out kMonalMessageFiletransferUpdateNotice"); + + //update db with content type and size + [[DataLayer sharedInstance] setMessageHistoryId:historyId filetransferMimeType:mimeType filetransferSize:contentLength]; + + //send out update notification (and update used MLMessage object directly instead of reloading it from db after updating the db) + msg.filetransferMimeType = mimeType; + msg.filetransferSize = contentLength; + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:msg.accountID]; + if(account != nil) //don't send out update notices for already deleted accounts + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMessageFiletransferUpdateNotice object:account userInfo:@{@"message": msg}]; + else + return; //abort here without autodownloading if account was already deleted + + //try to autodownload if sizes match + long autodownloadMaxSize = [[HelperTools defaultsDB] integerForKey:@"AutodownloadFiletransfersWifiMaxSize"]; + if([[MLXMPPManager sharedInstance] onMobile]) + autodownloadMaxSize = [[HelperTools defaultsDB] integerForKey:@"AutodownloadFiletransfersMobileMaxSize"]; + if( + [[HelperTools defaultsDB] boolForKey:@"AutodownloadFiletransfers"] && + [contentLength intValue] >= 0 && //-1 means we don't know the size --> don't autodownload files of unknown sizes + [contentLength integerValue] <= autodownloadMaxSize + ) + { + DDLogInfo(@"Autodownloading file"); + [self downloadFileForHistoryID:historyId andForceDownload:YES]; //ignore already existing _currentlyTransfering entry leftover from this header check + } + else + { + //check done, remove from "currently checking/downloading list" + [self markAsComplete:historyId]; + } + + }] resume]; + }); +} + ++(void) downloadFileForHistoryID:(NSNumber*) historyId +{ + [self downloadFileForHistoryID:historyId andForceDownload:NO]; +} + ++(void) downloadFileForHistoryID:(NSNumber*) historyId andForceDownload:(BOOL) forceDownload +{ + MLMessage* msg = [[DataLayer sharedInstance] messageForHistoryID:historyId]; + if(!msg) + { + DDLogError(@"historyId %@ does not yield an MLMessage object, aborting", historyId); + return; + } + //make sure we don't check or download this twice (but only do this if the download is not forced anyway) + @synchronized(_currentlyTransfering) + { + if(!forceDownload && [self isFileForHistoryIdInTransfer:historyId]) + { + DDLogDebug(@"Already checking/downloading this content, ignoring"); + return; + } + [_currentlyTransfering addObject:historyId]; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + DDLogInfo(@"Downloading file for historyID %@", historyId); + NSString* url = [self genCanonicalUrl:msg.messageText]; + NSURLComponents* urlComponents = [NSURLComponents componentsWithString:msg.messageText]; + if(!urlComponents) + { + DDLogError(@"url components decoding failed for %@", msg); + [self setErrorType:NSLocalizedString(@"Download error", @"") andErrorText:NSLocalizedString(@"Failed to decode download link", @"") forMessage:msg]; + [self markAsComplete:historyId]; + return; + } + + NSURLSession* session = [HelperTools createEphemeralURLSession]; + // set app defined description for download size checks + [session setSessionDescription:url]; + NSURLSessionDownloadTask* task = [session downloadTaskWithURL:[NSURL URLWithString:url] completionHandler:^(NSURL* _Nullable location, NSURLResponse* _Nullable response, NSError* _Nullable error) { + if(error) + { + DDLogError(@"File download for %@ failed: %@", msg, error); + [self setErrorType:NSLocalizedString(@"Download error", @"") andErrorText:[NSString stringWithFormat:NSLocalizedString(@"Failed to download file: %@", @""), error] forMessage:msg]; + [self markAsComplete:historyId]; + return; + } + + NSDictionary* headers = ((NSHTTPURLResponse*)response).allHeaderFields; + NSString* mimeType = [[headers objectForKey:@"Content-Type"] lowercaseString]; + if(!mimeType) + mimeType = @"application/octet-stream"; + + //try to deduce the content type from a given file extension if needed and possible + if([mimeType isEqualToString:@"application/octet-stream"]) + mimeType = [self getMimeTypeOfOriginalFile:urlComponents.path]; + + //make sure we *always* have a mime type + if(!mimeType) + mimeType = @"application/octet-stream"; + + NSString* cacheFile = [self calculateCacheFileForNewUrl:msg.messageText andMimeType:mimeType]; + + //encrypted filetransfer + if([[urlComponents.scheme lowercaseString] isEqualToString:@"aesgcm"]) + { + DDLogInfo(@"Decrypting encrypted filetransfer stored at '%@'...", location); + if(urlComponents.fragment.length < 88) + { + DDLogError(@"File download for %@ failed: %@", msg, error); + [self setErrorType:NSLocalizedString(@"Download error", @"") andErrorText:NSLocalizedString(@"Failed to decode encrypted link", @"") forMessage:msg]; + [self markAsComplete:historyId]; + return; + } + int ivLength = 24; + //format is iv+32byte key + NSData* key = [HelperTools dataWithHexString:[urlComponents.fragment substringWithRange:NSMakeRange(ivLength, 64)]]; + NSData* iv = [HelperTools dataWithHexString:[urlComponents.fragment substringToIndex:ivLength]]; + + //decrypt data with given key and iv + NSData* encryptedData = [NSData dataWithContentsOfURL:location]; + if(encryptedData && encryptedData.length > 0 && key && key.length == 32 && iv && iv.length == 12) + { + NSData* decryptedData = [AESGcm decrypt:encryptedData withKey:key andIv:iv withAuth:nil]; + if(decryptedData == nil) + { + DDLogError(@"File download decryption failed for %@", msg); + [self setErrorType:NSLocalizedString(@"Download error", @"") andErrorText:NSLocalizedString(@"Failed to decrypt download", @"") forMessage:msg]; + [self markAsComplete:historyId]; + return; + } + [decryptedData writeToFile:cacheFile options:NSDataWritingAtomic error:&error]; + if(error) + { + DDLogError(@"File download for %@ failed: %@", msg, error); + [self setErrorType:NSLocalizedString(@"Download error", @"") andErrorText:NSLocalizedString(@"Failed to write decrypted download into cache directory", @"") forMessage:msg]; + [self markAsComplete:historyId]; + return; + } + MLAssert([_fileManager fileExistsAtPath:cacheFile], @"cache file should be there!", (@{@"cacheFile": cacheFile})); + [HelperTools configureFileProtectionFor:cacheFile]; + } + else + { + DDLogError(@"Failed to decrypt file (iv, key, data length checks failed) for %@", msg); + [self setErrorType:NSLocalizedString(@"Download error", @"") andErrorText:NSLocalizedString(@"Failed to decrypt filetransfer", @"") forMessage:msg]; + [self markAsComplete:historyId]; + return; + } + } + else //cleartext filetransfer + { + //hardlink file to our cache directory + //it will be removed once this completion returnes, even if moved to a new location (this seems to be a ios16 bug) + DDLogInfo(@"Hardlinking downloaded file from '%@' to document cache at '%@'...", [location path], cacheFile); + error = [HelperTools hardLinkOrCopyFile:[location path] to:cacheFile]; + if(error) + { + DDLogError(@"File download for %@ failed: %@", msg, error); + [self setErrorType:NSLocalizedString(@"Download error", @"") andErrorText:[NSString stringWithFormat:NSLocalizedString(@"Failed to copy downloaded file into cache directory: %@", @""), error] forMessage:msg]; + [self markAsComplete:historyId]; + return; + } + MLAssert([_fileManager fileExistsAtPath:cacheFile], @"cache file should be there!", (@{@"cacheFile": cacheFile})); + [HelperTools configureFileProtectionFor:cacheFile]; + } + + //update MLMessage object with mime type and size + NSNumber* filetransferSize = @([[_fileManager attributesOfItemAtPath:cacheFile error:nil] fileSize]); + msg.filetransferMimeType = mimeType; + msg.filetransferSize = filetransferSize; + + //hardlink cache file if possible + [self hardlinkFileForMessage:msg]; + + DDLogDebug(@"Updating db and sending out kMonalMessageFiletransferUpdateNotice"); + //update db with content type and size + [[DataLayer sharedInstance] setMessageHistoryId:historyId filetransferMimeType:mimeType filetransferSize:filetransferSize]; + //send out update notification (using our directly update MLMessage object instead of reloading it from db after updating the db) + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:msg.accountID]; + if(account != nil) //don't send out update notices for already deleted accounts + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMessageFiletransferUpdateNotice object:account userInfo:@{@"message": msg}]; + else + [_fileManager removeItemAtPath:cacheFile error:nil]; + + //download done, remove from "currently checking/downloading list" + [self markAsComplete:historyId]; + }]; + [task resume]; + }); +} + +-(void) URLSession:(NSURLSession*) session downloadTask:(NSURLSessionDownloadTask*) downloadTask didWriteData:(int64_t) bytesWritten totalBytesWritten:(int64_t) totalBytesWritten totalBytesExpectedToWrite:(int64_t) totalBytesExpectedToWrite +{ + @synchronized(_expectedDownloadSizes) { + NSNumber* expectedSize = _expectedDownloadSizes[session.sessionDescription]; + if(expectedSize == nil) //don't allow downloads of files without size in http header + [downloadTask cancel]; + else if(totalBytesWritten >= expectedSize.intValue + 512 * 1024) //allow for a maximum of 512KiB of extra data + [downloadTask cancel]; + else // everything is ok + ; + } +} + +-(void) URLSession:(nonnull NSURLSession*) session downloadTask:(nonnull NSURLSessionDownloadTask*) downloadTask didFinishDownloadingToURL:(nonnull NSURL*) location +{ + @synchronized(_expectedDownloadSizes) { + [_expectedDownloadSizes removeObjectForKey:session.sessionDescription]; + } +} + + +$$class_handler(handleHardlinking, $$ID(xmpp*, account), $$ID(NSString*, cacheFile), $$ID((NSArray*), hardlinkPathComponents), $$BOOL(direct)) + NSError* error; + + if([HelperTools isAppExtension]) + { + DDLogWarn(@"NOT hardlinking cache file at '%@' into documents directory at '%@': we STILL are in the appex, rescheduling this to next account connect", cacheFile, [hardlinkPathComponents componentsJoinedByString:@"/"]); + //the reconnect handler framework will add $ID(account) to the callerArgs, no need to add an accountID etc. here + //direct=YES is indicating that this hardlinking handler was called directly instead of serializing/unserializing it to/from db + //AND that we are in the mainapp currently + //always use direct = NO here, to make sure the file is hardlinkable even if the direct handling depicted above changes and + //calls from the mainapp are serialized to db, too + [account addReconnectionHandler:$newHandler(self, handleHardlinking, + $ID(cacheFile), + $ID(hardlinkPathComponents), + $BOOL(direct, NO) + )]; + return; + } + + if(![_fileManager fileExistsAtPath:cacheFile]) + { + DDLogWarn(@"Could not hardlink cacheFile, file not present: %@", cacheFile); + return; + } + + @synchronized(_hardlinkingSyncObject) { + //copy file created in appex to a temporary location and then rename it to be at the original location + //this allows hardlinking later on because now the mainapp owns that file while it had only read/write access before + if(!direct) + { + NSString* cacheFileTMP = [cacheFile.stringByDeletingLastPathComponent stringByAppendingPathComponent:[NSString stringWithFormat:@"tmp.%@", cacheFile.lastPathComponent]]; + DDLogInfo(@"Copying appex-created cache file '%@' to '%@' before deleting old file and renaming our copy...", cacheFile, cacheFileTMP); + [_fileManager removeItemAtPath:cacheFileTMP error:nil]; //remove tmp file if already present + [_fileManager copyItemAtPath:cacheFile toPath:cacheFileTMP error:&error]; + if(error) + { + DDLogError(@"Could not copy cache file to tmp file: %@", error); +#ifdef DEBUG + @throw [NSException exceptionWithName:@"ERROR_WHILE_COPYING_CACHEFILE" reason:@"Could not copy cacheFile!" userInfo:@{ + @"cacheFile": cacheFile, + @"cacheFileTMP": cacheFileTMP + }]; +#endif + return; + } + + [_fileManager removeItemAtPath:cacheFile error:&error]; + if(error) + { + DDLogError(@"Could not delete original cache file: %@", error); +#ifdef DEBUG + @throw [NSException exceptionWithName:@"ERROR_WHILE_DELETING_CACHEFILE" reason:@"Could not delete cacheFile!" userInfo:@{ + @"cacheFile": cacheFile + }]; +#endif + return; + } + + [_fileManager moveItemAtPath:cacheFileTMP toPath:cacheFile error:&error]; + if(error) + { + DDLogError(@"Could not rename tmp file to cache file: %@", error); +#ifdef DEBUG + @throw [NSException exceptionWithName:@"ERROR_WHILE_RENAMING_CACHEFILE" reason:@"Could not rename cacheFileTMP to cacheFile!" userInfo:@{ + @"cacheFile": cacheFile, + @"cacheFileTMP": cacheFileTMP + }]; +#endif + return; + } + } + + if([[HelperTools defaultsDB] boolForKey:@"hardlinkFiletransfersIntoDocuments"]) + { + NSURL* hardLink = [[_fileManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject]; + for(NSString* pathComponent in hardlinkPathComponents) + hardLink = [hardLink URLByAppendingPathComponent:pathComponent]; + + DDLogInfo(@"Hardlinking cache file at '%@' into documents directory at '%@'...", cacheFile, hardLink); + if(![_fileManager fileExistsAtPath:[hardLink.URLByDeletingLastPathComponent path]]) + { + DDLogVerbose(@"Creating hardlinking dir struct at '%@'...", hardLink.URLByDeletingLastPathComponent); + [_fileManager createDirectoryAtURL:hardLink.URLByDeletingLastPathComponent withIntermediateDirectories:YES attributes:@{NSFileProtectionKey: NSFileProtectionCompleteUntilFirstUserAuthentication} error:&error]; + if(error) + DDLogWarn(@"Ignoring error creating hardlinking dir struct at '%@': %@", hardLink, error); + else + [HelperTools configureFileProtection:NSFileProtectionCompleteUntilFirstUserAuthentication forFile:[hardLink path]]; + } + + //don't throw any error if the file aready exists, because it could be a rare collision (we only use 16 bit random numbers to keep the file prefix short) + if([_fileManager fileExistsAtPath:[hardLink path]]) + DDLogWarn(@"Not hardlinking file '%@' to '%@': file already exists (maybe a rare collision?)...", cacheFile, hardLink); + else + { + DDLogVerbose(@"Hardlinking cache file '%@' to '%@'...", cacheFile, hardLink); + error = [HelperTools hardLinkOrCopyFile:cacheFile to:[hardLink path]]; + if(error) + { + DDLogError(@"Error creating hardlink: %@", error); + @throw [NSException exceptionWithName:@"ERROR_WHILE_HARDLINKING_FILE" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}]; + } + } + } + } +$$ + ++(void) hardlinkFileForMessage:(MLMessage*) msg +{ + NSDictionary* fileInfo = [self getFileInfoForMessage:msg]; + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:msg.accountID]; + if(account == nil) + return; + + NSString* groupDisplayName = nil; + NSString* fromDisplayName = nil; + MLContact* contact = [MLContact createContactFromJid:msg.buddyName andAccountID:msg.accountID]; + if(msg.isMuc) + { + groupDisplayName = contact.contactDisplayName; + fromDisplayName = msg.contactDisplayName; + } + else + fromDisplayName = contact.contactDisplayName; + + //this resembles to /Files// for 1:1 contacts and /Files/// for mucs (channels AND groups) + NSMutableArray* hardlinkPathComponents = [NSMutableArray new]; + [hardlinkPathComponents addObject:account.connectionProperties.identity.jid]; + if(groupDisplayName != nil) + [hardlinkPathComponents addObject:groupDisplayName]; + else + [hardlinkPathComponents addObject:fromDisplayName]; + + //put incoming and outgoing files in different directories + if(msg.inbound) + { + //put every mime-type in its own type directory + if([fileInfo[@"mimeType"] hasPrefix:@"image/"]) + [hardlinkPathComponents addObject:NSLocalizedString(@"Received Images", @"directory for downloaded images")]; + else if([fileInfo[@"mimeType"] hasPrefix:@"video/"]) + [hardlinkPathComponents addObject:NSLocalizedString(@"Received Videos", @"directory for downloaded videos")]; + else if([fileInfo[@"mimeType"] hasPrefix:@"audio/"]) + [hardlinkPathComponents addObject:NSLocalizedString(@"Received Audios", @"directory for downloaded audios")]; + else + [hardlinkPathComponents addObject:NSLocalizedString(@"Received Files", @"directory for downloaded files")]; + + //add fromDisplayName inside the "received xxx" dir so that the received and sent dirs are at the same level + if(groupDisplayName != nil) + [hardlinkPathComponents addObject:fromDisplayName]; + } + else + { + //put every mime-type in its own type directory + if([fileInfo[@"mimeType"] hasPrefix:@"image/"]) + [hardlinkPathComponents addObject:NSLocalizedString(@"Sent Images", @"directory for downloaded images")]; + else if([fileInfo[@"mimeType"] hasPrefix:@"video/"]) + [hardlinkPathComponents addObject:NSLocalizedString(@"Sent Videos", @"directory for downloaded videos")]; + else if([fileInfo[@"mimeType"] hasPrefix:@"audio/"]) + [hardlinkPathComponents addObject:NSLocalizedString(@"Sent Audios", @"directory for downloaded audios")]; + else + [hardlinkPathComponents addObject:NSLocalizedString(@"Sent Files", @"directory for downloaded files")]; + } + + u_int16_t i=(u_int16_t)arc4random(); + NSString* randomID = [HelperTools hexadecimalString:[NSData dataWithBytes: &i length: sizeof(i)]]; + NSString* fileExtension = [fileInfo[@"filename"] pathExtension]; + NSString* fileBasename = [fileInfo[@"filename"] stringByDeletingPathExtension]; + [hardlinkPathComponents addObject:[[NSString stringWithFormat:@"%@_%@", fileBasename, randomID] stringByAppendingPathExtension:fileExtension]]; + + MLAssert(fileInfo[@"cacheFile"] != nil, @"cacheFile should never be empty here!", (@{@"fileInfo": fileInfo})); + + MLHandler* handler = $newHandler(self, handleHardlinking, $ID(cacheFile, fileInfo[@"cacheFile"]), $ID(hardlinkPathComponents), $BOOL(direct, NO)); + if([HelperTools isAppExtension]) + { + DDLogWarn(@"NOT hardlinking cache file at '%@' into documents directory at %@: we are in the appex, rescheduling this to next account connect", fileInfo[@"cacheFile"], [hardlinkPathComponents componentsJoinedByString:@"/"]); + [account addReconnectionHandler:handler]; //the reconnect handler framework will add $ID(account) to the callerArgs, no need to add an accountID etc. here + } + else + $call(handler, $ID(account), $BOOL(direct, YES)); //no reconnect handler framework used, explicitly bind $ID(account) via callerArgs +} + ++(NSDictionary*) getFileInfoForMessage:(MLMessage*) msg +{ + MLAssert([msg.messageType isEqualToString:kMessageTypeFiletransfer], @"message not of type filetransfer!", (@{@"msg": msg})); + + NSURLComponents* urlComponents = [NSURLComponents componentsWithString:msg.messageText]; + //default is a dummy filename (used when the filename can not be extracted from url) + NSString* filename = [NSString stringWithFormat:@"%@.bin", [[NSUUID UUID] UUIDString]]; + if(urlComponents != nil && urlComponents.path) + filename = [urlComponents.path lastPathComponent]; + NSString* cacheFile = [self retrieveCacheFileForUrl:msg.messageText andMimeType:(msg.filetransferMimeType && ![msg.filetransferMimeType isEqualToString:@""] ? msg.filetransferMimeType : nil)]; + + //return every information we have + if(!cacheFile) + { + //if we have mimeype and size the http head request was already done, else we did not even do a head request + if(msg.filetransferMimeType != nil && msg.filetransferSize != nil) + return @{ + @"url": msg.messageText, + @"filename": filename, + @"needsDownloading": @YES, + @"mimeType": msg.filetransferMimeType, + @"size": msg.filetransferSize, + @"fileExtension": [filename pathExtension], + @"historyID": msg.messageDBId, + }; + else + return @{ + @"url": msg.messageText, + @"filename": filename, + @"needsDownloading": @YES, + @"fileExtension": [filename pathExtension], + @"historyID": msg.messageDBId, + }; + } + return @{ + @"url": msg.messageText, + @"filename": filename, + @"needsDownloading": @NO, + @"mimeType": [self getMimeTypeOfCacheFile:cacheFile], + @"size": @([[_fileManager attributesOfItemAtPath:cacheFile error:nil] fileSize]), + @"cacheId": [cacheFile lastPathComponent], + @"cacheFile": cacheFile, + @"fileExtension": [filename pathExtension], + @"historyID": msg.messageDBId, + }; +} + ++(void) deleteFileForMessage:(MLMessage*) msg +{ + if(![msg.messageType isEqualToString:kMessageTypeFiletransfer]) + return; + DDLogInfo(@"Deleting file for url %@", msg.messageText); + NSDictionary* info = [self getFileInfoForMessage:msg]; + if(info) + { + DDLogDebug(@"Deleting file in cache: %@", info[@"cacheFile"]); + [_fileManager removeItemAtPath:info[@"cacheFile"] error:nil]; + } +} + ++(MLHandler*) prepareDataUpload:(NSData*) data +{ + return [self prepareDataUpload:data withFileExtension:@"dat"]; +} + ++(MLHandler*) prepareDataUpload:(NSData*) data withFileExtension:(NSString*) fileExtension +{ + DDLogInfo(@"Preparing for upload of NSData object: %@", data); + + //save file data to our document cache (temporary filename because the upload url is unknown yet) + NSString* tempname = [NSString stringWithFormat:@"tmp.%@", [[NSUUID UUID] UUIDString]]; + NSError* error; + NSString* file = [_documentCacheDir stringByAppendingPathComponent:tempname]; + DDLogDebug(@"Tempstoring data at %@", file); + [data writeToFile:file options:NSDataWritingFileProtectionCompleteUntilFirstUserAuthentication error:&error]; + if(error) + { + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + DDLogError(@"Failed to save NSData to file: %@", error); + return $newHandler(self, errorCompletion, $ID(error)); + } + [HelperTools configureFileProtectionFor:file]; + + NSString* userFacingFilename = [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], fileExtension]; + return $newHandler(self, internalTmpFileUploadHandler, + $ID(file), + $ID(userFacingFilename), + $ID(mimeType, [self getMimeTypeOfOriginalFile:userFacingFilename]) + ); +} + ++(MLHandler*) prepareFileUpload:(NSURL*) fileUrl +{ + DDLogInfo(@"Preparing for upload of file stored at %@", [fileUrl path]); + + //copy file to our document cache (temporary filename because the upload url is unknown yet) + NSString* tempname = [NSString stringWithFormat:@"tmp.%@", [[NSUUID UUID] UUIDString]]; + NSError* error; + NSString* file = [_documentCacheDir stringByAppendingPathComponent:tempname]; + DDLogDebug(@"Tempstoring file at %@", file); + [_fileManager copyItemAtPath:[fileUrl path] toPath:file error:&error]; + if(error) + { + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + DDLogError(@"File upload failed: %@", error); + return $newHandler(self, errorCompletion, $ID(error)); + } + [HelperTools configureFileProtectionFor:file]; + + return $newHandler(self, internalTmpFileUploadHandler, + $ID(file), + $ID(userFacingFilename, [fileUrl lastPathComponent]), + $ID(mimeType, [self getMimeTypeOfOriginalFile:[fileUrl path]]) + ); +} + ++(MLHandler*) prepareUIImageUpload:(UIImage*) image +{ + DDLogInfo(@"Preparing for upload of image from UIImage object"); + double imageQuality = [[HelperTools defaultsDB] doubleForKey:@"ImageUploadQuality"]; + + //copy file to our document cache (temporary filename because the upload url is unknown yet) + NSString* tempname = [NSString stringWithFormat:@"tmp.%@", [[NSUUID UUID] UUIDString]]; + NSError* error; + NSString* file = [_documentCacheDir stringByAppendingPathComponent:tempname]; + DDLogDebug(@"Tempstoring jpeg encoded file having quality %f at %@", imageQuality, file); + NSData* imageData = UIImageJPEGRepresentation(image, imageQuality); + [imageData writeToFile:file options:NSDataWritingAtomic error:&error]; + if(error) + { + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + DDLogError(@"File upload failed: %@", error); + return $newHandler(self, errorCompletion, $ID(error)); + } + [HelperTools configureFileProtectionFor:file]; + + return $newHandler(self, internalTmpFileUploadHandler, + $ID(file), + $ID(userFacingFilename, ([NSString stringWithFormat:@"%@.jpg", [[NSUUID UUID] UUIDString]])), + $ID(mimeType, @"image/jpeg") + ); +} + +//proxy to allow calling the completion with a (possibly) serialized error +$$class_handler(errorCompletion, $$ID(NSError*, error), $$ID(monal_upload_completion_t, completion)) + completion(nil, nil, nil, error); +$$ + ++(void) uploadFile:(NSURL*) fileUrl onAccount:(xmpp*) account withEncryption:(BOOL) encrypted andCompletion:(monal_upload_completion_t) completion +{ + DDLogInfo(@"Uploading file stored at %@", [fileUrl path]); + //directly call internal file upload handler returned as MLHandler and bind our (non serializable) completion block to it + $call([self prepareFileUpload:fileUrl], $ID(account), $BOOL(encrypted), $ID(completion)); +} + ++(void) uploadUIImage:(UIImage*) image onAccount:(xmpp*) account withEncryption:(BOOL) encrypted andCompletion:(monal_upload_completion_t) completion +{ + DDLogInfo(@"Uploading image from UIImage object"); + //directly call internal file upload handler returned as MLHandler and bind our (non serializable) completion block to it + $call([self prepareUIImageUpload:image], $ID(account), $BOOL(encrypted), $ID(completion)); +} + ++(void) doStartupCleanup +{ + //delete leftover tmp files older than 1 day + NSDate* now = [NSDate date]; + NSArray* directoryContents = [_fileManager contentsOfDirectoryAtPath:_documentCacheDir error:nil]; + NSPredicate* filter = [NSPredicate predicateWithFormat:@"self BEGINSWITH 'tmp.'"]; + for(NSString* file in [directoryContents filteredArrayUsingPredicate:filter]) + { + NSURL* fileUrl = [NSURL fileURLWithPath:file]; + NSDate* fileDate; + NSError* error; + [fileUrl getResourceValue:&fileDate forKey:NSURLContentModificationDateKey error:&error]; + if(!error && [now timeIntervalSinceDate:fileDate]/86400 > 1) + { + DDLogInfo(@"Deleting leftover tmp file at %@", [_documentCacheDir stringByAppendingPathComponent:file]); + [_fileManager removeItemAtPath:[_documentCacheDir stringByAppendingPathComponent:file] error:nil]; + } + } + + //*** migrate old image store to new fileupload store if needed*** + if(![[HelperTools defaultsDB] boolForKey:@"ImageCacheMigratedToFiletransferCache"]) + { + DDLogInfo(@"Migrating old image store to new filetransfer cache"); + + //first of all upgrade all message types (needed to make getFileInfoForMessage: work later on) + [[DataLayer sharedInstance] upgradeImageMessagesToFiletransferMessages]; + + //copy all images listed in old imageCache db tables to our new filetransfer store + NSArray* paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString* documentsDirectory = [paths objectAtIndex:0]; + NSString* cachePath = [documentsDirectory stringByAppendingPathComponent:@"imagecache"]; + for(NSDictionary* img in [[DataLayer sharedInstance] getAllCachedImages]) + { + //extract old url, file and mime type + NSURLComponents* urlComponents = [NSURLComponents componentsWithString:img[@"url"]]; + if(!urlComponents) + continue; + NSString* mimeType = [self getMimeTypeOfOriginalFile:urlComponents.path]; + NSString* oldFile = [cachePath stringByAppendingPathComponent:img[@"path"]]; + NSString* newFile = [self calculateCacheFileForNewUrl:img[@"url"] andMimeType:mimeType]; + + DDLogInfo(@"Migrating old image cache file %@ (having mimeType %@) for URL %@ to new cache at %@", oldFile, mimeType, img[@"url"], newFile); + if([_fileManager fileExistsAtPath:oldFile]) + { + [_fileManager copyItemAtPath:oldFile toPath:newFile error:nil]; + [HelperTools configureFileProtectionFor:newFile]; + [_fileManager removeItemAtPath:oldFile error:nil]; + } + else + DDLogWarn(@"Old file not existing --> not moving file, but still updating db entries"); + + //update every history_db entry with new filetransfer metadata + //(this will flip the message type to kMessageTypeFiletransfer and set correct mimeType and size values) + NSArray* messageList = [[DataLayer sharedInstance] getAllMessagesForFiletransferUrl:img[@"url"]]; + if(![messageList count]) + { + DDLogWarn(@"No messages in history db having this url, deleting file completely"); + [_fileManager removeItemAtPath:newFile error:nil]; + } + else + { + DDLogInfo(@"Updating every history db entry with new filetransfer metadata: %lu messages", [messageList count]); + for(MLMessage* msg in messageList) + { + NSDictionary* info = [self getFileInfoForMessage:msg]; + DDLogDebug(@"FILETRANSFER INFO: %@", info); + //don't update mime type and size if we still need to download the file (both is unknown in this case) + if(info && ![info[@"needsDownloading"] boolValue]) + [[DataLayer sharedInstance] setMessageHistoryId:msg.messageDBId filetransferMimeType:info[@"mimeType"] filetransferSize:info[@"size"]]; + } + } + } + + //remove old db tables completely + [[DataLayer sharedInstance] removeImageCacheTables]; + [[HelperTools defaultsDB] setBool:YES forKey:@"ImageCacheMigratedToFiletransferCache"]; + DDLogInfo(@"Migration done"); + } +} + +#pragma mark - internal methods + ++(NSString*) retrieveCacheFileForUrl:(NSString*) url andMimeType:(NSString*) mimeType +{ + NSString* urlPart = [HelperTools hexadecimalString:[HelperTools sha256:[url dataUsingEncoding:NSUTF8StringEncoding]]]; + if(mimeType) + { + NSString* mimePart = [HelperTools hexadecimalString:[mimeType dataUsingEncoding:NSUTF8StringEncoding]]; + + //the cache filename consists of a hash of the upload url (in hex) followed of the file mimetype (also in hex) as file extension + NSString* cacheFile = [_documentCacheDir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", urlPart, mimePart]]; + + //file having the supplied mimeType exists + if([_fileManager fileExistsAtPath:cacheFile]) + return cacheFile; + } + + //check for files having a different mime type but the same base url + NSString* predicateString = [NSString stringWithFormat:@"self BEGINSWITH '%@.'", urlPart]; + NSArray* directoryContents = [_fileManager contentsOfDirectoryAtPath:_documentCacheDir error:nil]; + NSPredicate* filter = [NSPredicate predicateWithFormat:predicateString]; + for(NSString* file in [directoryContents filteredArrayUsingPredicate:filter]) + return [_documentCacheDir stringByAppendingPathComponent:file]; + + //nothing found + DDLogVerbose(@"Could not find cache file for url '%@' having mime type '%@'...", url, mimeType); + return nil; +} + ++(NSString*) calculateCacheFileForNewUrl:(NSString*) url andMimeType:(NSString*) mimeType +{ + //the cache filename consists of a hash of the upload url (in hex) followed of the file mimetype (also in hex) as file extension + NSString* urlPart = [HelperTools hexadecimalString:[HelperTools sha256:[url dataUsingEncoding:NSUTF8StringEncoding]]]; + NSString* mimePart = [HelperTools hexadecimalString:[mimeType dataUsingEncoding:NSUTF8StringEncoding]]; + return [_documentCacheDir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", urlPart, mimePart]]; +} + ++(NSString*) genCanonicalUrl:(NSString*) url +{ + NSURLComponents* urlComponents = [NSURLComponents componentsWithString:url]; + if(!urlComponents) + { + DDLogWarn(@"Failed to get url components, returning empty url!"); + return @""; + } + if([[urlComponents.scheme lowercaseString] isEqualToString:@"aesgcm"]) + urlComponents.scheme = @"https"; + if(![[urlComponents.scheme lowercaseString] isEqualToString:@"https"]) + { + DDLogWarn(@"Failed to get url components, returning empty url!"); + return @""; + } + urlComponents.fragment = @""; //make sure we don't leak urlfragments to upload server + return urlComponents.string; +} + ++(NSString*) getMimeTypeOfOriginalFile:(NSString*) file +{ + UTType* type = [UTType typeWithTag:[file pathExtension] tagClass:UTTagClassFilenameExtension conformingToType:UTTypeData]; + if(type.preferredMIMEType == nil) + return @"application/octet-stream"; + return type.preferredMIMEType; +} + ++(NSString*) getMimeTypeOfCacheFile:(NSString*) file +{ + return [[NSString alloc] initWithData:[HelperTools dataWithHexString:[file pathExtension]] encoding:NSUTF8StringEncoding]; +} + ++(void) setErrorType:(NSString*) errorType andErrorText:(NSString*) errorText forMessage:(MLMessage*) msg +{ + //update db + [[DataLayer sharedInstance] + setMessageId:msg.messageId + andJid:msg.buddyName + errorType:errorType + errorReason:errorText + ]; + + //inform chatview of error + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMessageErrorNotice object:nil userInfo:@{ + @"MessageID": msg.messageId, + @"jid": msg.buddyName, + @"errorType": errorType, + @"errorReason": errorText + }]; +} + +$$class_handler(internalTmpFileUploadHandler, $$ID(NSString*, file), $$ID(NSString*, userFacingFilename), $$ID(NSString*, mimeType), $$ID(xmpp*, account), $$BOOL(encrypted), $$ID(monal_upload_completion_t, completion)) + NSError* error; + + //make sure we don't upload the same tmpfile twice (should never happen anyways) + @synchronized(_currentlyTransfering) + { + if([self isFileAtPathInTransfer:file]) + { + error = [NSError errorWithDomain:@"MonalError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Already uploading this content, ignoring", @"")}]; + DDLogError(@"Already uploading this content, ignoring %@", file); + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + return completion(nil, nil, nil, error); + } + [_currentlyTransfering addObject:file]; + } + + //TODO: allow real file based transfers instead of NSData based transfers + DDLogDebug(@"Reading file data into NSData object"); + NSData* fileData = [[NSData alloc] initWithContentsOfFile:file options:0 error:&error]; + if(error) + { + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + [self markAsComplete:file]; + DDLogError(@"File upload failed: %@", error); + return completion(nil, nil, nil, error); + } + + //encrypt data (TODO: do this in a streaming fashion, e.g. from file to tmpfile and stream this tmpfile via http afterwards) + MLEncryptedPayload* encryptedPayload; + if(encrypted) + { + DDLogInfo(@"Encrypting file data before upload"); + encryptedPayload = [AESGcm encrypt:fileData keySize:32]; + if(encryptedPayload && encryptedPayload.body != nil) + { + NSMutableData* encryptedData = [encryptedPayload.body mutableCopy]; + [encryptedData appendData:encryptedPayload.authTag]; + fileData = encryptedData; + } + else + { + NSError* error = [NSError errorWithDomain:@"MonalError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to encrypt file", @"")}]; + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + [self markAsComplete:file]; + DDLogError(@"File upload failed: %@", error); + return completion(nil, nil, nil, error); + } + } + + //make sure we don't leak information about encrypted files + NSString* sendMimeType = mimeType; + if(encrypted) + sendMimeType = @"application/octet-stream"; + + MLAssert(fileData != nil, @"fileData should never be nil!"); + MLAssert(userFacingFilename != nil, @"userFacingFilename should never be nil!"); + MLAssert(sendMimeType != nil, @"sendMimeType should never be nil!"); + + DDLogDebug(@"Requesting file upload slot for mimeType %@", sendMimeType); + [account requestHTTPSlotWithParams:@{ + @"data":fileData, + @"fileName":userFacingFilename, + @"contentType":sendMimeType + } andCompletion:^(NSString* url, NSError* error) { + if(error) + { + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + [self markAsComplete:file]; + DDLogError(@"File upload failed: %@", error); + return completion(nil, nil, nil, error); + } + + NSURLComponents* urlComponents = [NSURLComponents componentsWithString:url]; + if(url && urlComponents) + { + //build aesgcm url containing "aesgcm" url-scheme and IV and AES-key in urlfragment + if(encrypted) + { + urlComponents.scheme = @"aesgcm"; + urlComponents.fragment = [NSString stringWithFormat:@"%@%@", + [HelperTools hexadecimalString:encryptedPayload.iv], + //extract real aes key without authtag (32 bytes = 256bit) (conversations compatibility) + [HelperTools hexadecimalString:[encryptedPayload.key subdataWithRange:NSMakeRange(0, 32)]]]; + url = urlComponents.string; + } + + //ignore upload if account was already removed + if([[MLXMPPManager sharedInstance] getEnabledAccountForID:account.accountID] == nil) + { + NSError* error = [NSError errorWithDomain:@"MonalError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to upload file: account was removed", @"")}]; + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + [self markAsComplete:file]; + DDLogError(@"File upload failed: %@", error); + return completion(nil, nil, nil, error); + } + + //move the tempfile to our cache location + NSString* cacheFile = [self calculateCacheFileForNewUrl:url andMimeType:mimeType]; + DDLogInfo(@"Moving (possibly encrypted) file to our document cache at %@", cacheFile); + [_fileManager moveItemAtPath:file toPath:cacheFile error:&error]; + if(error) + { + NSError* error = [NSError errorWithDomain:@"MonalError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to move uploaded file to file cache directory", @"")}]; + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + [self markAsComplete:file]; + DDLogError(@"File upload failed: %@", error); + return completion(nil, nil, nil, error); + } + [HelperTools configureFileProtectionFor:cacheFile]; + + [self markAsComplete:file]; + DDLogInfo(@"URL for download: %@", url); + return completion(url, mimeType, [NSNumber numberWithInteger:fileData.length], nil); + } + else + { + NSError* error = [NSError errorWithDomain:@"MonalError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to parse URL returned by HTTP upload server", @"")}]; + [_fileManager removeItemAtPath:file error:nil]; //remove temporary file + [self markAsComplete:file]; + DDLogError(@"File upload failed: %@", error); + return completion(nil, nil, nil, error); + } + }]; +$$ + ++(void) markAsComplete:(id) obj +{ + @synchronized(_currentlyTransfering) { + [_currentlyTransfering removeObject:obj]; + } + if(self.isIdle) + //don't queue this notification because it should be handled immediately + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalFiletransfersIdle object:self]; +} + ++(BOOL) isFileForHistoryIdInTransfer:(NSNumber*) historyId +{ + if([_currentlyTransfering containsObject:historyId]) + return YES; + return NO; +} + ++(BOOL) isFileAtPathInTransfer:(NSString*) path +{ + if([_currentlyTransfering containsObject:path]) + return YES; + return NO; +} +@end diff --git a/Monal/Classes/MLHTTPRequest.h b/Monal/Classes/MLHTTPRequest.h new file mode 100644 index 0000000..5346686 --- /dev/null +++ b/Monal/Classes/MLHTTPRequest.h @@ -0,0 +1,24 @@ +// +// MLHTTPRequest.h +// +// +// Created by Anurodh Pokharel on 9/16/15. +// Copyright © 2015 Anurodh Pokharel. All rights reserved. +// + +#import +#import "MLConstants.h" + +#define kGet @"GET" +#define kPost @"POST" +#define kPut @"PUT" + +@interface MLHTTPRequest : NSObject + +/** + Performs a HTTP call with the specified verb (GET, PUT, POST etc) to a url . Completion handler will be called with the result as dictinary or array. + @param postedData optional + */ ++ (void) sendWithVerb:(NSString *) verb path:(NSString *)path headers:(NSDictionary *) headers withArguments:(NSDictionary *) arguments data:(NSData *) postedData andCompletionHandler:(void (^)(NSError *error, id result)) completion; + +@end diff --git a/Monal/Classes/MLHTTPRequest.m b/Monal/Classes/MLHTTPRequest.m new file mode 100644 index 0000000..ad88b61 --- /dev/null +++ b/Monal/Classes/MLHTTPRequest.m @@ -0,0 +1,107 @@ +// +// MLHTTPRequest.h +// +// +// Created by Anurodh Pokharel on 9/16/15. +// Copyright © 2015 Anurodh Pokharel. All rights reserved. +// + +#import "MLHTTPRequest.h" +#import "HelperTools.h" + + +@interface MLHTTPRequest () + +@end + +@implementation MLHTTPRequest + ++(NSData*) httpBodyForDictionary:(NSDictionary*) arguments +{ + unsigned int keyCounter = 0; + if(arguments) { + NSMutableString* postString =[NSMutableString new]; + for (NSString *key in arguments) { + + NSString *value=[arguments objectForKey:key]; + value= [value stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + + [postString appendString:[NSString stringWithFormat:@"%@=%@", key, value]]; + if(keyCounter < [arguments allKeys].count - 1) + { + [postString appendString:@"&"]; + } + keyCounter++; + } + return [postString dataUsingEncoding:NSUTF8StringEncoding]; + } else + { + return nil; + } + +} + + ++(void) sendWithVerb:(NSString*) verb path:(NSString*) path headers:(NSDictionary*) headers withArguments:(NSDictionary*) arguments data:(NSData*) postedData andCompletionHandler:(void (^)(NSError *error, id result)) completion +{ + NSMutableURLRequest* theRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:path] + cachePolicy:NSURLRequestReloadIgnoringCacheData + timeoutInterval:60.0]; + if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) + theRequest.requiresDNSSECValidation = YES; + [theRequest setHTTPMethod:verb]; + + NSData* dataToSubmit = postedData; + + [headers enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop __unused) { + [theRequest addValue:obj forHTTPHeaderField:key]; + }]; + + if([verb isEqualToString:kPost]||[verb isEqualToString:kPut]) { + if(arguments && !postedData) { + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:arguments options:0 error:nil]; + // NSString* jsonString = [[NSString alloc] initWithBytes:[jsonData bytes] length:[jsonData length] encoding:NSUTF8StringEncoding]; + [theRequest addValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + dataToSubmit=jsonData; + } + else + { + dataToSubmit = postedData; + } + } + + DDLogVerbose(@"Calling: %@ %@", verb, path); + + NSURLSession* session = [HelperTools createEphemeralURLSession]; + void (^completeBlock)(NSData*,NSURLResponse*,NSError*)= ^(NSData* data,NSURLResponse* response, NSError* connectionError) + { + + NSError* errorReply; + + if(connectionError) + { + errorReply = connectionError; //[NSError errorWithDomain:@"HTTP" code:0 userInfo:@{@"result":@"connection error"}]; + } + else + { + NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*) response; + if(!(httpResponse.statusCode >= 200 && httpResponse.statusCode <= 399)) + { + errorReply = [NSError errorWithDomain:@"HTTP" code:httpResponse.statusCode userInfo:@{@"result":[NSHTTPURLResponse localizedStringForStatusCode:httpResponse.statusCode]}]; + } + } + completion(errorReply,data); + }; + + if(([verb isEqualToString:kPost]||[verb isEqualToString:kPut]) && dataToSubmit) + { + [[session uploadTaskWithRequest:theRequest fromData:dataToSubmit + completionHandler:completeBlock] resume]; + } + else { + [[session dataTaskWithRequest:theRequest + completionHandler:completeBlock] resume]; + } +} + +@end diff --git a/Monal/Classes/MLHandler.h b/Monal/Classes/MLHandler.h new file mode 100644 index 0000000..74aff08 --- /dev/null +++ b/Monal/Classes/MLHandler.h @@ -0,0 +1,178 @@ +// +// MLHandler.h +// monalxmpp +// +// Created by Thilo Molitor on 29.10.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +/**************************** **************************** + +- Define handler method (this will be a static class method and doesn't +have to be declared in any interface to be usable). The argument number +or order does not matter, feel free to reorder or even remove arguments +you don't need. Arguments declared with $$-prefix are mandatory, arguments +with $_-prefix are optional. +Primitive datatypes like BOOL, int etc. can not be imported as optional. +Syntax: +``` +$$class_handler(myHandlerName, $_ID(xmpp*, account), $$BOOL(success)) + // your code comes here + // variables defined/imported: account (optional), success (mandatory) +$$ +``` + +Instance handlers are instance methods instead of static methods. +You need to specify on which instance these handlers should operate. +The instance extraxtion statement (the second argument to $$instance_handler() can be everything that +returns an objc object. For example: "account.omemo" or "[account getInstanceToUse]" or just "account". +Synax: +``` +$$instance_handler(myHandlerName, instanceToUse, $$ID(xmpp*, account), $$BOOL(success)) + // your code comes here + // 'self' is now the instance of the class extracted by the instanceToUse statement. + // instead of the class instance as it would be if $$class_handler() was used instead of $$instance_handler() + // variables defined/imported: account, success (both mandatory) +$$ +``` + +- Call defined handlers by: +``` +MLHandler* h = $newHandler(ClassName, myHandlerName); +$call(h); +``` + +- You can bind variables to MLHandler objects when creating them and when +invoking them. Variables supplied on invocation overwrite variables +supplied when creating the handler if the names are equal. +Variables bound to the handler when creating it have to conform to the +NSCoding protocol to make the handler serializable. +Variable binding example: +``` +NSString* var1 = @"value"; +MLHandler* h = $newHandler(ClassName, myHandlerName, + $ID(var1), + $BOOL(success, YES) +})); +xmpp* account = nil; +$call(h, $ID(account), $ID(otherAccountVarWithSameValue, account)) +``` + +- Usable shortcuts to create MLHandler objects: + - $newHandler(ClassName, handlerName, boundArgs...) + - $newHandlerWithInvalidation(ClassName, handlerName, invalidationHandlerName, boundArgs...) + +- You can add an invalidation method to a handler when creating the +MLHandler object (after invalidating a handler you can not call or +invalidate it again!). Invalidation handlers can be instance handlers or static handlers, +just like with "normal" handlers: +``` +// definition of normal handler method as instance_handler +$$instance_handler(myHandlerName, [account getInstanceToUse], $_ID(xmpp*, account), $$BOOL(success)) + // your code comes here + // 'self' is now the instance of the class extracted by [account getInstanceToUse] + // instead of the class instance as it would be if $$class_handler() was used instead of $$instance_handler() +$$ + +// definition of invalidation method +$$class_handler(myInvalidationHandlerName, $$BOOL(done), $_ID(NSString*, var1)) + // your code comes here + // variables imported: var1, done + // variables that could have been imported according to $newHandler and $call below: var1, success, done +$$ + +MLHandler* h = $newHandlerWithInvalidation(ClassName, myHandlerName, myInvalidationHandlerName, + $ID(var1, @"value"), + $BOOL(success, YES) +})); + +// call invalidation method with "done" argument set to YES +$invalidate(h, $BOOL(done, YES)) +``` + +**************************** ****************************/ + +#include "metamacros.h" + +//we need this in here, even if MLConstants.h was not included +#ifndef STRIP_PARENTHESES + //see https://stackoverflow.com/a/62984543/3528174 + #define STRIP_PARENTHESES(X) __ESC(__ISH X) + #define __ISH(...) __ISH __VA_ARGS__ + #define __ESC(...) __ESC_(__VA_ARGS__) + #define __ESC_(...) __VAN ## __VA_ARGS__ + #define __VAN__ISH +#endif + +//create handler object or bind vars to existing handler +#define $newHandler(delegate, name, ...) _Pragma("clang diagnostic push") _Pragma("clang diagnostic ignored \"-Wundeclared-selector\"") [[MLHandler alloc] initWithDelegate:[delegate class] handlerName:@#name andBoundArguments:@{ __VA_ARGS__ }] _Pragma("clang diagnostic pop") +#define $newHandlerWithInvalidation(delegate, name, invalidation, ...) _Pragma("clang diagnostic push") _Pragma("clang diagnostic ignored \"-Wundeclared-selector\"") [[MLHandler alloc] initWithDelegate:[delegate class] handlerName:@#name invalidationHandlerName:@#invalidation andBoundArguments:@{ __VA_ARGS__ }] _Pragma("clang diagnostic pop") +#define $bindArgs(handler, ...) [handler bindArguments:@{ __VA_ARGS__ }] +#define $ID(name, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( @#name : nilWrapper(name) )( _packID(name, __VA_ARGS__) ) +#define $HANDLER(name, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( @#name : nilWrapper(name) )( _packID(name, __VA_ARGS__) ) +#define $BOOL(name, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( @#name : [NSNumber numberWithBool: name ] )( _packBOOL(name, __VA_ARGS__) ) +#define $INT(name, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( @#name : [NSNumber numberWithInt: name ] )( _packINT(name, __VA_ARGS__) ) +#define $DOUBLE(name, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( @#name : [NSNumber numberWithDouble: name ] )( _packDOUBLE(name, __VA_ARGS__) ) +#define $INTEGER(name, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( @#name : [NSNumber numberWithInteger: name ] )( _packINTEGER(name, __VA_ARGS__) ) +#define $UINTEGER(name, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( @#name : [NSNumber numberWithUnsignedInteger: name ] )( _packUINTEGER(name, __VA_ARGS__) ) + +//declare handler, the order of provided arguments does not matter because we use named arguments +#define $$class_handler(name, ...) +(void) MLHandler_##name##_withArguments:(NSDictionary*) _callerArgs andBoundArguments:(NSDictionary*) _boundArgs { metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( )( metamacro_foreach(_expand_import, ;, __VA_ARGS__) ); +#define $$instance_handler(name, instance, ...) +(void) MLHandler_##name##_withArguments:(NSDictionary*) _callerArgs andBoundArguments:(NSDictionary*) _boundArgs { metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( )( metamacro_foreach(_expand_import, ;, __VA_ARGS__) ); [instance MLInstanceHandler_##name##_withArguments:_callerArgs andBoundArguments:_boundArgs]; } -(void) MLInstanceHandler_##name##_withArguments:(NSDictionary*) _callerArgs andBoundArguments:(NSDictionary*) _boundArgs { metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))( )( metamacro_foreach(_expand_import, ;, __VA_ARGS__) ); +#define $_ID(type, var) (STRIP_PARENTHESES(type) var __unused = _callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]; if(var != nil && NSClassFromString(type_to_classname(@metamacro_foreach(_foreach_stringify, ",", STRIP_PARENTHESES(type)))) != nil && ![var isKindOfClass:NSClassFromString(type_to_classname(@metamacro_foreach(_foreach_stringify, ",", STRIP_PARENTHESES(type))))]) [MLHandler throwDynamicExceptionForType:@"!"#type andVar:@#var andUserData:(@{@"actualType": NSStringFromClass([var class]), @"expectedType": @#type, @"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__];) +#define $$ID(type, var) (STRIP_PARENTHESES(type) var __unused = _callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]; if(var == nil) [MLHandler throwDynamicExceptionForType:@"$$"#type andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; else if(NSClassFromString(type_to_classname(@metamacro_foreach(_foreach_stringify, ",", STRIP_PARENTHESES(type)))) != nil && ![var isKindOfClass:NSClassFromString(type_to_classname(@metamacro_foreach(_foreach_stringify, ",", STRIP_PARENTHESES(type))))]) [MLHandler throwDynamicExceptionForType:@"!"#type andVar:@#var andUserData:(@{@"actualType": NSStringFromClass([var class]), @"expectedType": @#type, @"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__];) +#define $_HANDLER(var) (MLHandler* var __unused = _callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]; if(var != nil &&![var isKindOfClass:[MLHandler class]]) [MLHandler throwDynamicExceptionForType:@"!MLHandler" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]) +#define $$HANDLER(var) (MLHandler* var __unused = _callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]; if(var == nil) [MLHandler throwDynamicExceptionForType:@"$$MLHandler" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; if(![var isKindOfClass:[MLHandler class]]) [MLHandler throwDynamicExceptionForType:@"!MLHandler" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]) +#define $$BOOL(var) (if(_callerArgs[@#var]==nil && _boundArgs[@#var]==nil) [MLHandler throwDynamicExceptionForType:@"$$BOOL" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; if(![(_callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]) isKindOfClass:[NSNumber class]]) [MLHandler throwDynamicExceptionForType:@"!NSNumber(BOOL)" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; BOOL var __unused = _callerArgs[@#var] ? [_callerArgs[@#var] boolValue] : [_boundArgs[@#var] boolValue]) +#define $$INT(var) (if(_callerArgs[@#var]==nil && _boundArgs[@#var]==nil) [MLHandler throwDynamicExceptionForType:@"$$int" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; if(![(_callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]) isKindOfClass:[NSNumber class]]) [MLHandler throwDynamicExceptionForType:@"!NSNumber(int)" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; int var __unused = _callerArgs[@#var] ? [_callerArgs[@#var] intValue] : [_boundArgs[@#var] intValue]) +#define $$DOUBLE(var) (if(_callerArgs[@#var]==nil && _boundArgs[@#var]==nil) [MLHandler throwDynamicExceptionForType:@"$$double" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; if(![(_callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]) isKindOfClass:[NSNumber class]]) [MLHandler throwDynamicExceptionForType:@"!NSNumber(double)" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; double var __unused = _callerArgs[@#var] ? [_callerArgs[@#var] doubleValue] : [_boundArgs[@#var] doubleValue]) +#define $$INTEGER(var) (if(_callerArgs[@#var]==nil && _boundArgs[@#var]==nil) [MLHandler throwDynamicExceptionForType:@"$$NSInteger" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; if(![(_callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]) isKindOfClass:[NSNumber class]]) [MLHandler throwDynamicExceptionForType:@"!NSNumber(NSInteger)" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; NSInteger var __unused = _callerArgs[@#var] ? [_callerArgs[@#var] integerValue] : [_boundArgs[@#var] integerValue]) +#define $$UINTEGER(var) (if(_callerArgs[@#var]==nil && _boundArgs[@#var]==nil) [MLHandler throwDynamicExceptionForType:@"$$NSUInteger" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; if(![(_callerArgs[@#var] ? _callerArgs[@#var] : _boundArgs[@#var]) isKindOfClass:[NSNumber class]]) [MLHandler throwDynamicExceptionForType:@"!NSNumber(NSUInteger)" andVar:@#var andUserData:(@{@"_boundArgs": _boundArgs, @"_callerArgs": _callerArgs}) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__]; NSUInteger var __unused = _callerArgs[@#var] ? [_callerArgs[@#var] unsignedIntegerValue] : [_boundArgs[@#var] unsignedIntegerValue]) +#define $$ } + +//call handler/invalidation +#define $call(handler, ...) do { if(handler != nil) { [handler callWithArguments:@{ __VA_ARGS__ }]; } } while(0) +#define $invalidate(handler, ...) do { if(handler != nil) { [handler invalidateWithArguments:@{ __VA_ARGS__ }]; } } while(0) + +//internal stuff +//$_*() and $$*() will add parentheses around its result to make sure all inner commas like those probably exposed by an inner STRIP_PARENTHESES() call get not +//interpreted as multiple arguments by metamacro_foreach() +//These additional parentheses around the result have to be stripped again by this call to STRIP_PARENTHESES() here +#define _expand_import(num, param) STRIP_PARENTHESES(param) +#define _packID(name, value, ...) @#name : nilWrapper(value) +#define _packHANDLER(name, value, ...) @#name : nilWrapper(value) +#define _packBOOL(name, value, ...) @#name : [NSNumber numberWithBool: value ] +#define _packINT(name, value, ...) @#name : [NSNumber numberWithInt: value ] +#define _packDOUBLE(name, value, ...) @#name : [NSNumber numberWithDouble: value ] +#define _packINTEGER(name, value, ...) @#name : [NSNumber numberWithInteger: value ] +#define _packUINTEGER(name, value, ...) @#name : [NSNumber numberWithUnsignedInteger: value ] +#define _foreach_stringify(_, var) metamacro_stringify(var) + + +NS_ASSUME_NONNULL_BEGIN + +NSString* type_to_classname(NSString* type); + +@interface MLHandler : NSObject +{ +} ++(BOOL) supportsSecureCoding; ++(void) throwDynamicExceptionForType:(NSString*) type andVar:(NSString*) varName andUserData:(id) userInfo andFile:(char*) file andLine:(int) line andFunc:(char*) func; + +//id of this handler (consisting of class name, method name and invalidation method name) +@property (readonly, strong) NSString* id; + +//init +-(instancetype) initWithDelegate:(id) delegate handlerName:(NSString*) handlerName andBoundArguments:(NSDictionary*) args; +-(instancetype) initWithDelegate:(id) delegate handlerName:(NSString*) handlerName invalidationHandlerName:(NSString*) invalidationHandlerName andBoundArguments:(NSDictionary*) args; + +//bind new arguments dictionary +-(void) bindArguments:(NSDictionary* _Nullable) args; + +//call and invalidate +-(void) callWithArguments:(NSDictionary* _Nullable) defaultArgs; +-(void) invalidateWithArguments:(NSDictionary* _Nullable) args; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLHandler.m b/Monal/Classes/MLHandler.m new file mode 100644 index 0000000..fd18d2c --- /dev/null +++ b/Monal/Classes/MLHandler.m @@ -0,0 +1,212 @@ +// +// MLHandler.m +// monalxmpp +// +// Created by Thilo Molitor on 29.10.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" +#import "MLHandler.h" +#import "HelperTools.h" + +#define HANDLER_VERSION 1 + +@interface MLHandler () +{ + NSMutableDictionary* _internalData; + BOOL _invalidated; +} +@end + +NSString* type_to_classname(NSString* type) +{ + return [type componentsSeparatedByCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"*< "]][0]; +} + +@implementation MLHandler + +-(instancetype) init +{ + self = [super init]; + return self; +} + +-(instancetype) initWithDelegate:(id) delegate handlerName:(NSString*) handlerName andBoundArguments:(NSDictionary*) args +{ + self = [self init]; + return [self INTERNALinitWithDelegate:delegate handlerName:handlerName invalidationHandlerName:nil andBoundArguments:args]; +} + +-(instancetype) initWithDelegate:(id) delegate handlerName:(NSString*) handlerName invalidationHandlerName:(NSString*) invalidationHandlerName andBoundArguments:(NSDictionary*) args +{ + self = [self init]; + return [self INTERNALinitWithDelegate:delegate handlerName:handlerName invalidationHandlerName:invalidationHandlerName andBoundArguments:args]; +} + +-(instancetype) INTERNALinitWithDelegate:(id) delegate handlerName:(NSString*) handlerName invalidationHandlerName:(NSString* _Nullable) invalidationHandlerName andBoundArguments:(NSDictionary* _Nullable) args +{ + if(![delegate respondsToSelector:[self handlerNameToSelector:handlerName]]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:[NSString stringWithFormat:@"Class '%@' does not provide handler implementation '%@'!", NSStringFromClass(delegate), handlerName] userInfo:@{ + @"delegate": NSStringFromClass(delegate), + @"handlerSelector": NSStringFromSelector([self handlerNameToSelector:handlerName]), + }]; + if(invalidationHandlerName && ![delegate respondsToSelector:[self handlerNameToSelector:invalidationHandlerName]]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:[NSString stringWithFormat:@"Class '%@' does not provide invalidation implementation '%@'!", NSStringFromClass(delegate), invalidationHandlerName] userInfo:@{ + @"delegate": NSStringFromClass(delegate), + @"handlerSelector": NSStringFromSelector([self handlerNameToSelector:handlerName]), + @"invalidationSelector": NSStringFromSelector([self handlerNameToSelector:invalidationHandlerName]), + }]; + _internalData = [NSMutableDictionary new]; + _invalidated = NO; + [_internalData addEntriesFromDictionary:@{ + @"version": @(HANDLER_VERSION), + @"delegate": NSStringFromClass(delegate), + @"handlerName": handlerName, + }]; + if(invalidationHandlerName) + _internalData[@"invalidationName"] = invalidationHandlerName; + [self bindArguments:args]; + return self; +} + +-(void) bindArguments:(NSDictionary* _Nullable) args +{ + [self checkInvalidation]; + _internalData[@"boundArguments"] = [self sanitizeArguments:args]; +} + +-(void) callWithArguments:(NSDictionary* _Nullable) args +{ + MLAssert(_internalData[@"delegate"] && _internalData[@"handlerName"], @"Tried to call MLHandler while delegate and/or handlerName was not set!", @{@"handler": _internalData}); + [self checkInvalidation]; + args = [self sanitizeArguments:args]; + id delegate = NSClassFromString(_internalData[@"delegate"]); + SEL sel = [self handlerNameToSelector:_internalData[@"handlerName"]]; + if(![delegate respondsToSelector:sel]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:[NSString stringWithFormat:@"Class '%@' does not provide handler implementation '%@'!", _internalData[@"delegate"], _internalData[@"handlerName"]] userInfo:@{ + @"delegate": _internalData[@"delegate"], + @"handlerSelector": NSStringFromSelector(sel), + }]; + DDLogVerbose(@"Calling handler %@...", self); + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:[delegate methodSignatureForSelector:sel]]; + [inv setTarget:delegate]; + [inv setSelector:sel]; + //arguments 0 and 1 are self and _cmd respectively, automatically set by NSInvocation + //default arguments of the caller + [inv setArgument:(void* _Nonnull)&args atIndex:2]; + //bound arguments of the handler + //make sure we use a copy because we don't want to leak changes of our bound arguments dict into already running invocations + NSDictionary* boundArgs = [_internalData[@"boundArguments"] copy]; + [inv setArgument:(void* _Nonnull)&boundArgs atIndex:3]; + //now call it + [inv invoke]; +} + +-(void) invalidateWithArguments:(NSDictionary* _Nullable) args +{ + if(!(_internalData[@"delegate"] && _internalData[@"invalidationName"])) + return; + [self checkInvalidation]; + args = [self sanitizeArguments:args]; + id delegate = NSClassFromString(_internalData[@"delegate"]); + SEL sel = [self handlerNameToSelector:_internalData[@"invalidationName"]]; + if(![delegate respondsToSelector:sel]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:[NSString stringWithFormat:@"Class '%@' does not provide invalidation implementation '%@'!", _internalData[@"delegate"], _internalData[@"invalidationName"]] userInfo:@{ + @"delegate": _internalData[@"delegate"], + @"handlerSelector": NSStringFromSelector([self handlerNameToSelector:_internalData[@"handlerName"]]), + @"invalidationSelector": NSStringFromSelector(sel), + }]; + DDLogVerbose(@"Calling invalidation %@...", self); + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:[delegate methodSignatureForSelector:sel]]; + [inv setTarget:delegate]; + [inv setSelector:sel]; + //arguments 0 and 1 are self and _cmd respectively, automatically set by NSInvocation + //default arguments of the caller + [inv setArgument:(void* _Nonnull)&args atIndex:2]; + //bound arguments of the handler + NSDictionary* boundArgs = _internalData[@"boundArguments"]; + [inv setArgument:(void* _Nonnull)&boundArgs atIndex:3]; + //now call it + [inv invoke]; + _invalidated = YES; +} + +-(NSString*) id +{ + if(!_internalData[@"delegate"] || !_internalData[@"handlerName"]) + return @"{emptyHandler}"; + NSString* extras = @""; + if(_internalData[@"invalidationName"]) + extras = [NSString stringWithFormat:@"<%@>", _internalData[@"invalidationName"]]; + return [NSString stringWithFormat:@"%@|%@%@", _internalData[@"delegate"], _internalData[@"handlerName"], extras]; +} + +-(NSString*) description +{ + NSString* extras = @""; + if(_internalData[@"invalidationName"]) + extras = [NSString stringWithFormat:@"<%@>", _internalData[@"invalidationName"]]; + return [NSString stringWithFormat:@"{%@, %@%@}", _internalData[@"delegate"], _internalData[@"handlerName"], extras]; +} + ++(BOOL) supportsSecureCoding +{ + return YES; +} + +-(void) encodeWithCoder:(NSCoder*) coder +{ + [coder encodeObject:_internalData forKey:@"internalData"]; + [coder encodeBool:_invalidated forKey:@"invalidated"]; +} + +-(instancetype) initWithCoder:(NSCoder*) coder +{ + self = [super init]; + _internalData = [coder decodeObjectForKey:@"internalData"]; + _invalidated = [coder decodeBoolForKey:@"invalidated"]; + return self; +} + +-(id) copyWithZone:(NSZone*) zone +{ + MLHandler* copy = [[[self class] alloc] init]; + copy->_internalData = [[NSMutableDictionary alloc] initWithDictionary:_internalData copyItems:YES]; + copy->_invalidated = _invalidated; + return copy; +} + +//this removes NSNull references from arguments altogether +-(NSMutableDictionary*) sanitizeArguments:(NSDictionary* _Nullable) args +{ + NSMutableDictionary* retval = [NSMutableDictionary new]; + if(args) + for(NSString* key in args) + if(args[key] != [NSNull null]) + retval[key] = args[key]; + return retval; +} + +-(void) checkInvalidation +{ + if(_invalidated) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Tried to call or bind vars to already invalidated handler!" userInfo:@{ + @"handler": _internalData, + }]; +} + +-(SEL) handlerNameToSelector:(NSString*) handlerName +{ + return NSSelectorFromString([NSString stringWithFormat:@"MLHandler_%@_withArguments:andBoundArguments:", handlerName]); +} + ++(void) throwDynamicExceptionForType:(NSString*) type andVar:(NSString*) varName andUserData:(id) userInfo andFile:(char*) file andLine:(int) line andFunc:(char*) func +{ + NSString* text = [NSString stringWithFormat:@"Dynamic unpacking exception triggered for '%@' var '%@' at %@:%d in %s", type, varName, [HelperTools sanitizeFilePath:file], line, func]; + DDLogError(@"%@", text); + @throw [NSException exceptionWithName:text reason:text userInfo:userInfo]; +} + +@end diff --git a/Monal/Classes/MLIQProcessor.h b/Monal/Classes/MLIQProcessor.h new file mode 100644 index 0000000..42d4c6b --- /dev/null +++ b/Monal/Classes/MLIQProcessor.h @@ -0,0 +1,32 @@ +// +// MLIQProcessor.h +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import +#import "XMPPIQ.h" +#import "MLXMPPConnection.h" +#import "XMPPIQ.h" +#import "XMPPDataForm.h" +#import "MLXMLNode.h" +#import "xmpp.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^iqCompletion)(MLXMLNode* iq, monal_iq_handler_t resultHandler, monal_iq_handler_t errorHandler); +typedef void (^iqDelegateCompletion)(MLXMLNode* iq, id delegate, SEL method, NSArray* args); +typedef void (^processAction)(void); + +@interface MLIQProcessor : NSObject + +/** + Process a iq, persist any changes and post notifications + */ ++(void) processUnboundIq:(XMPPIQ*) iqNode forAccount:(xmpp*) account; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLIQProcessor.m b/Monal/Classes/MLIQProcessor.m new file mode 100644 index 0000000..571901e --- /dev/null +++ b/Monal/Classes/MLIQProcessor.m @@ -0,0 +1,822 @@ +// +// MLIQProcessor.m +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import +#import "MLIQProcessor.h" +#import "MLConstants.h" +#import "MLHandler.h" +#import "DataLayer.h" +#import "MLImageManager.h" +#import "HelperTools.h" +#import "MLNotificationQueue.h" +#import "MLContactSoftwareVersionInfo.h" +#import "MLOMEMO.h" + + +/** + Validate and process any iq elements. + @link https://xmpp.org/rfcs/rfc6120.html#stanzas-semantics-iq + */ +@implementation MLIQProcessor + ++(void) processUnboundIq:(XMPPIQ*) iqNode forAccount:(xmpp*) account +{ + //only handle these iqs if the remote user is on our roster, + //if the are coming from our own domain, + //or if they are from a muc group, not a channel + MLContact* contact = [MLContact createContactFromJid:iqNode.fromUser andAccountID:account.accountID]; + if(!( + //we have to check for .isMuc because mucs always set .isSubscribedFrom to YES + (!contact.isMuc && contact.isSubscribedFrom) || + contact.isSelfChat || + [account.connectionProperties.identity.domain isEqualToString:iqNode.fromUser] || + (contact.isMuc && [kMucTypeGroup isEqualToString:contact.mucType]) + )) + DDLogWarn(@"Invalid sender for iq (!subscribedFrom || isMuc), ignoring: %@", iqNode); + + if([iqNode check:@"/"]) + [self processGetIq:iqNode forAccount:account]; + else if([iqNode check:@"/"]) + [self processSetIq:iqNode forAccount:account]; + else if([iqNode check:@"/"]) + [self processResultIq:iqNode forAccount:account]; + else if([iqNode check:@"/"]) + [self processErrorIq:iqNode forAccount:account]; + else + DDLogWarn(@"Ignoring invalid iq type: %@", [iqNode findFirst:@"/@type"]); +} + ++(void) processGetIq:(XMPPIQ*) iqNode forAccount:(xmpp*) account +{ + if([iqNode check:@"{urn:xmpp:ping}ping"]) + { + XMPPIQ* pong = [[XMPPIQ alloc] initAsResponseTo:iqNode]; + [pong setiqTo:iqNode.from]; + [account send:pong]; + return; + } + + if([iqNode check:@"{jabber:iq:version}query"] && [[HelperTools defaultsDB] boolForKey: @"allowVersionIQ"]) + { + XMPPIQ* versioniq = [[XMPPIQ alloc] initAsResponseTo:iqNode]; + [versioniq setiqTo:iqNode.from]; + [versioniq setVersion]; + [account send:versioniq]; + return; + } + + if([iqNode check:@"{http://jabber.org/protocol/disco#info}query"]) + { + XMPPIQ* discoInfoResponse = [[XMPPIQ alloc] initAsResponseTo:iqNode]; + [discoInfoResponse setDiscoInfoWithFeatures:account.capsFeatures identity:account.capsIdentity andNode:[iqNode findFirst:@"{http://jabber.org/protocol/disco#info}query@node"]]; + [account send:discoInfoResponse]; + return; + } + + DDLogWarn(@"Got unhandled get IQ: %@", iqNode); + [self respondWithErrorTo:iqNode onAccount:account]; +} + ++(void) processSetIq:(XMPPIQ*) iqNode forAccount:(xmpp*) account +{ + //these iqs will be ignored if not matching an outgoing or incoming call + //--> no presence leak if the call was not outgoing, because the jmi stanzas creating the call will + //not be processed without isSubscribedFrom in the first place + if(([iqNode check:@"{urn:xmpp:jingle:1}jingle"] && ![iqNode check:@"{urn:xmpp:jingle:1}jingle"])) + { + [[MLNotificationQueue currentQueue] postNotificationName:kMonalIncomingSDP object:account userInfo:@{@"iqNode": iqNode}]; + return; + } + if([iqNode check:@"{urn:xmpp:jingle:1}jingle"]) + { + [[MLNotificationQueue currentQueue] postNotificationName:kMonalIncomingICECandidate object:account userInfo:@{@"iqNode": iqNode}]; + return; + } + + //its a roster push (sanity check will be done in processRosterWithAccount:andIqNode:) + if([iqNode check:@"{jabber:iq:roster}query"]) + { + //this will only return YES, if the roster push was allowed and processed successfully + if([self processRosterWithAccount:account andIqNode:iqNode]) + { + //send empty result iq as per RFC 6121 requirements + XMPPIQ* reply = [[XMPPIQ alloc] initAsResponseTo:iqNode]; + [reply setiqTo:iqNode.from]; + [account send:reply]; + } + return; + } + + if([iqNode check:@"{urn:xmpp:blocking}block"] || [iqNode check:@"{urn:xmpp:blocking}unblock"]) + { + //make sure we don't process blocking updates not coming from our own account + if([account.connectionProperties.serverDiscoFeatures containsObject:@"urn:xmpp:blocking"] && (iqNode.from == nil || [iqNode.fromUser isEqualToString:account.connectionProperties.identity.jid])) + { + BOOL blockingUpdated = NO; + // mark jid as unblocked + if([iqNode check:@"{urn:xmpp:blocking}unblock"]) + { + NSArray* unBlockItems = [iqNode find:@"{urn:xmpp:blocking}unblock/item@@"]; + for(NSDictionary* item in unBlockItems) + { + if(item && item[@"jid"]) + [[DataLayer sharedInstance] unBlockJid:item[@"jid"] withAccountID:account.accountID]; + } + if(unBlockItems && unBlockItems.count == 0) + { + // remove all blocks + [account updateLocalBlocklistCache:[[NSSet alloc] init]]; + } + blockingUpdated = YES; + } + // mark jid as blocked + if([iqNode check:@"{urn:xmpp:blocking}block"]) + { + for(NSDictionary* item in [iqNode find:@"{urn:xmpp:blocking}block/item@@"]) + { + if(item && item[@"jid"]) + [[DataLayer sharedInstance] blockJid:item[@"jid"] withAccountID:account.accountID]; + } + blockingUpdated = YES; + } + if(blockingUpdated) + { + // notify the views + [[MLNotificationQueue currentQueue] postNotificationName:kMonalBlockListRefresh object:account userInfo:@{@"accountID": account.accountID}]; + } + } + else + DDLogWarn(@"Invalid sender for blocklist, ignoring iq: %@", iqNode); + return; + } + + DDLogWarn(@"Got unhandled set IQ: %@", iqNode); + [self respondWithErrorTo:iqNode onAccount:account]; +} + ++(void) processResultIq:(XMPPIQ*) iqNode forAccount:(xmpp*) account +{ + //WARNING: be careful adding stateless result handlers here (those can impose security risks!) + + DDLogWarn(@"Got unhandled result IQ: %@", iqNode); + [self respondWithErrorTo:iqNode onAccount:account]; +} + ++(void) processErrorIq:(XMPPIQ*) iqNode forAccount:(xmpp*) account +{ + DDLogWarn(@"Got unhandled error IQ: %@", iqNode); +} + +$$class_handler(handleCatchup, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$BOOL(secondTry)) + if([iqNode check:@"/"]) + { + DDLogWarn(@"Mam catchup query returned error: %@", [iqNode findFirst:@"error"]); + + //handle weird XEP-0313 monkey-patching XEP-0059 behaviour (WHY THE HELL??) + if(!secondTry && [iqNode check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}item-not-found"]) + { + //latestMessage can be nil, thus [latestMessage timestamp] will return nil and setMAMQueryAfterTimestamp:nil + //will query the whole archive since dawn of time + MLMessage* latestMessage = [[DataLayer sharedInstance] messageForHistoryID:[[DataLayer sharedInstance] getBiggestHistoryId]]; + DDLogInfo(@"Querying COMPLETE muc mam:2 archive at %@ after timestamp %@ for catchup", account.connectionProperties.identity.jid, [latestMessage timestamp]); + XMPPIQ* mamQuery = [[XMPPIQ alloc] initWithType:kiqSetType]; + [mamQuery setMAMQueryAfterTimestamp:[latestMessage timestamp]]; + [account sendIq:mamQuery withHandler:$newHandler(self, handleCatchup, $BOOL(secondTry, YES))]; + } + else + { + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"Failed to query for new messages on account %@", @""), account.connectionProperties.identity.jid] withNode:iqNode andAccount:account andIsSevere:YES]; + [account mamFinishedFor:account.connectionProperties.identity.jid]; + } + return; + } + if(![[iqNode findFirst:@"{urn:xmpp:mam:2}fin@complete|bool"] boolValue] && [iqNode check:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]) + { + DDLogVerbose(@"Paging through mam catchup results at %@ with after: %@", account.connectionProperties.identity.jid, [iqNode findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]); + //do RSM forward paging + XMPPIQ* pageQuery = [[XMPPIQ alloc] initWithType:kiqSetType]; + [pageQuery setMAMQueryAfter:[iqNode findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]]; + [account sendIq:pageQuery withHandler:$newHandler(self, handleCatchup, $BOOL(secondTry, NO))]; + } + else if([[iqNode findFirst:@"{urn:xmpp:mam:2}fin@complete|bool"] boolValue]) + { + DDLogVerbose(@"Mam catchup finished for %@", account.connectionProperties.identity.jid); + [account mamFinishedFor:account.connectionProperties.identity.jid]; + } +$$ + +$$class_handler(handleMamResponseWithLatestId, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogWarn(@"Mam latest stanzaid query %@ returned error: %@", iqNode.id, [iqNode findFirst:@"error"]); + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"Failed to query newest stanzaid for account %@", @""), account.connectionProperties.identity.jid] withNode:iqNode andAccount:account andIsSevere:YES]; + return; + } + DDLogVerbose(@"Got latest stanza id to prime database with: %@", [iqNode findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]); + //only do this if we got a valid stanza id (not null) + //if we did not get one we will get one when receiving the next message in this smacks session + //if the smacks session times out before we get a message and someone sends us one or more messages before we had a chance to establish + //a new smacks session, this messages will get lost because we don't know how to query the archive for this message yet + //once we successfully receive the first mam-archived message stanza (could even be an XEP-184 ack for a sent message), + //no more messages will get lost + //we ignore this single message loss here, because it should be super rare and solving it would be really complicated + if([iqNode check:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]) + [[DataLayer sharedInstance] setLastStanzaId:[iqNode findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"] forAccount:account.accountID]; + [account mamFinishedFor:account.connectionProperties.identity.jid]; +$$ + +$$class_handler(handleCarbonsEnabled, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogWarn(@"carbon enable iq returned error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"Failed to enable carbons for account %@", @""), account.connectionProperties.identity.jid] withNode:iqNode andAccount:account andIsSevere:YES]; + return; + } + account.connectionProperties.usingCarbons2 = YES; +$$ + +$$class_handler(handleBind, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogWarn(@"Binding our resource returned an error: %@", [iqNode findFirst:@"error"]); + if([iqNode check:@"error"]) + { + [HelperTools postError:NSLocalizedString(@"XMPP Bind Error", @"") withNode:iqNode andAccount:account andIsSevere:YES]; + [account disconnect]; //don't try again until next process start/unfreeze + } + else if([iqNode check:@"error"]) + [account bindResource:[HelperTools encodeRandomResource]]; //try to bind a new resource + else + [account reconnect]; //just try to reconnect (wait error type and all other error types not expected for bind) + return; + } + + DDLogInfo(@"Now bound to fullJid: %@", [iqNode findFirst:@"{urn:ietf:params:xml:ns:xmpp-bind}bind/jid#"]); + [account.connectionProperties.identity bindJid:[iqNode findFirst:@"{urn:ietf:params:xml:ns:xmpp-bind}bind/jid#"]]; + DDLogDebug(@"bareJid=%@, resource=%@, fullJid=%@", account.connectionProperties.identity.jid, account.connectionProperties.identity.resource, account.connectionProperties.identity.fullJid); + + //update resource in db (could be changed by server) + NSMutableDictionary* accountDict = [[NSMutableDictionary alloc] initWithDictionary:[[DataLayer sharedInstance] detailsForAccount:account.accountID]]; + accountDict[kResource] = account.connectionProperties.identity.resource; + [[DataLayer sharedInstance] updateAccounWithDictionary:accountDict]; + + if(account.connectionProperties.supportsSM3) + { + MLXMLNode *enableNode = [[MLXMLNode alloc] + initWithElement:@"enable" + andNamespace:@"urn:xmpp:sm:3" + withAttributes:@{@"resume": @"true"} + andChildren:@[] + andData:nil + ]; + [account send:enableNode]; + } + else + { + //init session and query disco, roster etc. + [account initSession]; + } +$$ + +//proxy handler +$$class_handler(handleRoster, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + [self processRosterWithAccount:account andIqNode:iqNode]; +$$ + ++(BOOL) processRosterWithAccount:(xmpp*) account andIqNode:(XMPPIQ*) iqNode +{ + //check sanity of from according to RFC 6121: + // https://tools.ietf.org/html/rfc6121#section-2.1.3 (roster get) + // https://tools.ietf.org/html/rfc6121#section-2.1.6 (roster push) + if( + iqNode.from != nil && + ![iqNode.from isEqualToString:account.connectionProperties.identity.jid] && + ![iqNode.from isEqualToString:account.connectionProperties.identity.domain] + ) + { + DDLogWarn(@"Invalid sender for roster, ignoring iq: %@", iqNode); + return NO; + } + + if([iqNode check:@"/"]) + { + DDLogWarn(@"Roster query returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:NSLocalizedString(@"XMPP Roster Error", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + return NO; + } + + NSArray* rosterList = [iqNode find:@"{jabber:iq:roster}query/item"]; + for(MLXMLNode* contactNode in rosterList) + { + NSMutableDictionary* contact = [contactNode findFirst:@"/@@"]; + + //ignore roster entries without jid (is this even possible?) + if(contact[@"jid"] == nil) + continue; + + //ignore roster entries providing a full jid instead of bare jids (is that even legitimate?) + NSDictionary* splitJid = [HelperTools splitJid:contact[@"jid"]]; + if(splitJid[@"resource"] != nil) + continue; + + contact[@"jid"] = [[NSString stringWithFormat:@"%@", contact[@"jid"]] lowercaseString]; + MLContact* contactObj = [MLContact createContactFromJid:contact[@"jid"] andAccountID:account.accountID]; + BOOL isKnownUser = [[DataLayer sharedInstance] contactDictionaryForUsername:contact[@"jid"] forAccount:account.accountID] != nil; + if([[contact objectForKey:@"subscription"] isEqualToString:kSubRemove]) + { + if(contactObj.isMuc) + DDLogWarn(@"Got roster remove request for MUC, ignoring it (possibly even triggered by us)."); + else + { + [[DataLayer sharedInstance] removeBuddy:contact[@"jid"] forAccount:account.accountID]; + [contactObj removeShareInteractions]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRemoved object:account userInfo:@{@"contact": contactObj}]; + } + } + else + { + if([[contact objectForKey:@"subscription"] isEqualToString:kSubFrom]) //already subscribed + { + [[DataLayer sharedInstance] deleteContactRequest:contactObj]; + } + else if([[contact objectForKey:@"subscription"] isEqualToString:kSubBoth]) + { + // We and the contact are interested + [[DataLayer sharedInstance] deleteContactRequest:contactObj]; + } + + if(contactObj.isMuc) + { + DDLogWarn(@"Removing muc '%@' from contactlist, got 'normal' roster entry!", contact[@"jid"]); + [[DataLayer sharedInstance] removeBuddy:contact[@"jid"] forAccount:account.accountID]; + [contactObj removeShareInteractions]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRemoved object:account userInfo:@{@"contact": contactObj}]; + contactObj = [MLContact createContactFromJid:contact[@"jid"] andAccountID:account.accountID]; + } + + DDLogVerbose(@"Adding contact %@ (%@) to database", contact[@"jid"], [contact objectForKey:@"name"]); + [[DataLayer sharedInstance] addContact:contact[@"jid"] + forAccount:account.accountID + nickname:[contact objectForKey:@"name"] ? [contact objectForKey:@"name"] : @""]; + + DDLogVerbose(@"Setting subscription status '%@' (ask=%@) for contact %@", contact[@"subscription"], contact[@"ask"], contact[@"jid"]); + [[DataLayer sharedInstance] setSubscription:[contact objectForKey:@"subscription"] + andAsk:[contact objectForKey:@"ask"] + forContact:contact[@"jid"] + andAccount:account.accountID]; + + NSSet* groups = [NSSet setWithArray:[contactNode find:@"group#"]]; + DDLogVerbose(@"Setting following groups: %@ for contact %@", groups, contact[@"jid"]); + [[DataLayer sharedInstance] setGroups:groups + forContact:contact[@"jid"] + inAccount:account.accountID]; + +#ifndef DISABLE_OMEMO + if(contactObj.isMuc == NO) + { + //request omemo devicelist, but only if this is a new user + //(we could get a roster with already known users if roster version is not supported by the server) + if(!isKnownUser && !([contact[@"subscription"] isEqualToString:kSubBoth] || [contact[@"subscription"] isEqualToString:kSubTo])) + [account.omemo subscribeAndFetchDevicelistIfNoSessionExistsForJid:contact[@"jid"]]; + } +#endif// DISABLE_OMEMO + + //regenerate avatar if the nickame has changed + if(![contactObj.nickName isEqualToString:[contact objectForKey:@"name"]]) + [[MLImageManager sharedInstance] purgeCacheForContact:contact[@"jid"] andAccount:account.accountID]; + + //TODO: save roster groups to new db table + + //send out kMonalContactRefresh notification + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": [MLContact createContactFromJid:contact[@"jid"] andAccountID:account.accountID] + }]; + } + } + + if([iqNode check:@"{jabber:iq:roster}query@ver"]) + [[DataLayer sharedInstance] setRosterVersion:[iqNode findFirst:@"{jabber:iq:roster}query@ver"] forAccount:account.accountID]; + + return YES; +} + +//features advertised on our own jid/account +$$class_handler(handleAccountDiscoInfo, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogError(@"Disco info query to our account returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:NSLocalizedString(@"XMPP Account Info Error", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + return; + } + + NSSet* features = [NSSet setWithArray:[iqNode find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; + account.connectionProperties.accountDiscoFeatures = features; + + if( + [iqNode check:@"{http://jabber.org/protocol/disco#info}query/identity"] && //xep-0163 support + [features containsObject:@"http://jabber.org/protocol/pubsub#filtered-notifications"] && //needed for xep-0163 support + [features containsObject:@"http://jabber.org/protocol/pubsub#publish-options"] && //needed for xep-0223 support + //important xep-0060 support (aka basic support) + [features containsObject:@"http://jabber.org/protocol/pubsub#publish"] && + [features containsObject:@"http://jabber.org/protocol/pubsub#subscribe"] && + [features containsObject:@"http://jabber.org/protocol/pubsub#create-nodes"] && + [features containsObject:@"http://jabber.org/protocol/pubsub#delete-items"] && + [features containsObject:@"http://jabber.org/protocol/pubsub#delete-nodes"] && + [features containsObject:@"http://jabber.org/protocol/pubsub#persistent-items"] && + [features containsObject:@"http://jabber.org/protocol/pubsub#retrieve-items"] && + //not advertised in ejabberd 22.05 but supported + //[features containsObject:@"http://jabber.org/protocol/pubsub#config-node"] && + [features containsObject:@"http://jabber.org/protocol/pubsub#auto-create"] && + // [features containsObject:@"http://jabber.org/protocol/pubsub#last-published"] && + // [features containsObject:@"http://jabber.org/protocol/pubsub#create-and-configure"] && + YES + ) { + DDLogInfo(@"Supports pubsub (pep)"); + account.connectionProperties.supportsPubSub = YES; + + //modern pep support + account.connectionProperties.supportsModernPubSub = NO; + if( + //needed for xep-0402 + [features containsObject:@"http://jabber.org/protocol/pubsub#item-ids"] && + [features containsObject:@"http://jabber.org/protocol/pubsub#multi-items"] && + YES + ) { + DDLogInfo(@"Supports modern pep multi-items"); + account.connectionProperties.supportsModernPubSub = YES; + } + + account.connectionProperties.supportsPubSubMax = NO; + if([features containsObject:@"http://jabber.org/protocol/pubsub#config-node-max"]) + { + DDLogInfo(@"Supports pep 'max' item count"); + account.connectionProperties.supportsPubSubMax = YES; + } + } + + //bookmarks2 needs modern pubsub features + if(account.connectionProperties.supportsModernPubSub && [features containsObject:@"urn:xmpp:bookmarks:1#compat-pep"]) + { + DDLogInfo(@"supports XEP-0402 compat-pep"); + account.connectionProperties.supportsBookmarksCompat = YES; + } + + if([features containsObject:@"urn:xmpp:push:0"]) + { + DDLogInfo(@"supports push"); + [account enablePush]; + } + + if([features containsObject:@"urn:xmpp:mam:2"]) + { + DDLogInfo(@"supports mam:2"); + + //query mam since last received stanza ID because we could not resume the smacks session + //(we would not have landed here if we were able to resume the smacks session) + //this will do a catchup of everything we might have missed since our last connection + //we possibly receive sent messages, too (this will update the stanzaid in database and gets deduplicate by messageid, + //which is guaranteed to be unique (because monal uses uuids for outgoing messages) + NSString* lastStanzaId = [[DataLayer sharedInstance] lastStanzaIdForAccount:account.accountID]; + [account delayIncomingMessageStanzasForArchiveJid:account.connectionProperties.identity.jid]; + XMPPIQ* mamQuery = [[XMPPIQ alloc] initWithType:kiqSetType]; + if(lastStanzaId) + { + DDLogInfo(@"Querying mam:2 archive after stanzaid '%@' for catchup", lastStanzaId); + [mamQuery setMAMQueryAfter:lastStanzaId]; + [account sendIq:mamQuery withHandler:$newHandler(self, handleCatchup, $BOOL(secondTry, NO))]; + } + else + { + DDLogInfo(@"Querying mam:2 archive for latest stanzaid to prime database"); + [mamQuery setMAMQueryForLatestId]; + [account sendIq:mamQuery withHandler:$newHandler(self, handleMamResponseWithLatestId)]; + } + } + else + { + //we don't support MAM --> tell the system to finish the catchup without MAM + DDLogError(@"Server does not support MAM, marking mam catchup as 'finished' for jid %@", account.connectionProperties.identity.jid); + [account mamFinishedFor:account.connectionProperties.identity.jid]; + } + + atomic_thread_fence(memory_order_seq_cst); //make sure our connection properties are "visible" to other threads before marking them as such + account.connectionProperties.accountDiscoDone = YES; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalAccountDiscoDone object:account]; +$$ + +//features advertised on our server +$$class_handler(handleServerDiscoInfo, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogError(@"Disco info query to our server returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:NSLocalizedString(@"XMPP Disco Info Error", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + return; + } + + NSSet* features = [NSSet setWithArray:[iqNode find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; + account.connectionProperties.serverDiscoFeatures = features; + + if([features containsObject:@"urn:xmpp:carbons:2"]) + { + DDLogInfo(@"got disco result with carbons ns"); + if(!account.connectionProperties.usingCarbons2) + { + DDLogInfo(@"enabling carbons"); + XMPPIQ* carbons = [[XMPPIQ alloc] initWithType:kiqSetType]; + [carbons addChildNode:[[MLXMLNode alloc] initWithElement:@"enable" andNamespace:@"urn:xmpp:carbons:2"]]; + [account sendIq:carbons withHandler:$newHandler(self, handleCarbonsEnabled)]; + } + } + + if([features containsObject:@"urn:xmpp:blocking"]) + [account fetchBlocklist]; + + if(!account.connectionProperties.supportsHTTPUpload && [features containsObject:@"urn:xmpp:http:upload:0"]) + { + DDLogInfo(@"supports http upload with server: %@", iqNode.from); + account.connectionProperties.supportsHTTPUpload = YES; + account.connectionProperties.uploadServer = iqNode.from; + account.connectionProperties.uploadSize = [[iqNode findFirst:@"{http://jabber.org/protocol/disco#info}query/\\{urn:xmpp:http:upload:0}result@max-file-size\\|int"] integerValue]; + DDLogInfo(@"Upload max filesize: %lu", account.connectionProperties.uploadSize); + } + + //query external services to learn stun/turn servers + if([features containsObject:@"urn:xmpp:extdisco:2"]) + [account queryExternalServicesOn:iqNode.fromUser]; + + //get the server's contact addresses (XEP-0157) + XMPPDataForm* dataForm = [iqNode findFirst:@"{http://jabber.org/protocol/disco#info}query/\\{http://jabber.org/network/serverinfo}result\\"]; + NSMutableDictionary* resultDictionary = [NSMutableDictionary dictionary]; + for (NSString* fieldName in dataForm.allKeys) + { + if ([fieldName hasSuffix:@"-addresses"]) + { + NSArray* addresses = [dataForm getField:fieldName][@"allValues"]; + if (addresses != nil && addresses.count > 0) + resultDictionary[fieldName] = addresses; + } + } + account.connectionProperties.serverContactAddresses = [resultDictionary copy]; +$$ + +$$class_handler(handleServiceDiscoInfo, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + NSSet* features = [NSSet setWithArray:[iqNode find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; + + if(!account.connectionProperties.supportsHTTPUpload && [features containsObject:@"urn:xmpp:http:upload:0"]) + { + DDLogInfo(@"supports http upload with server: %@", iqNode.from); + account.connectionProperties.supportsHTTPUpload = YES; + account.connectionProperties.uploadServer = iqNode.from; + account.connectionProperties.uploadSize = [[iqNode findFirst:@"{http://jabber.org/protocol/disco#info}query/\\{urn:xmpp:http:upload:0}result@max-file-size\\|int"] integerValue]; + DDLogInfo(@"Upload max filesize: %lu", account.connectionProperties.uploadSize); + } + + if([features containsObject:@"http://jabber.org/protocol/muc"]) + account.connectionProperties.conferenceServers[iqNode.fromUser] = [iqNode findFirst:@"{http://jabber.org/protocol/disco#info}query"]; + + //query external services to learn stun/turn servers + if([features containsObject:@"urn:xmpp:extdisco:2"]) + [account queryExternalServicesOn:iqNode.fromUser]; +$$ + +$$class_handler(handleServerDiscoItems, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + account.connectionProperties.discoveredServices = [NSMutableArray new]; + for(NSDictionary* item in [iqNode find:@"{http://jabber.org/protocol/disco#items}query/item@@"]) + { + [account.connectionProperties.discoveredServices addObject:item]; + if(![[item objectForKey:@"jid"] isEqualToString:account.connectionProperties.identity.domain]) + { + XMPPIQ* discoInfo = [[XMPPIQ alloc] initWithType:kiqGetType]; + [discoInfo setiqTo:[item objectForKey:@"jid"]]; + [discoInfo setDiscoInfoNode]; + [account sendIq:discoInfo withHandler:$newHandler(self, handleServiceDiscoInfo)]; + } + } +$$ + +$$class_handler(handleAdhocDisco, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogError(@"Adhoc command disco query to '%@' returned an error: %@", iqNode.from, [iqNode findFirst:@"error"]); + return; + } + + account.connectionProperties.discoveredAdhocCommands = [NSMutableDictionary new]; + for(MLXMLNode* item in [iqNode find:@"{http://jabber.org/protocol/disco#items}query/item"]) + { + if(![[item findFirst:@"/@jid"] isEqualToString:account.connectionProperties.identity.domain]) + continue; + account.connectionProperties.discoveredAdhocCommands[[item findFirst:@"/@node"]] = nilWrapper([item findFirst:@"/@name"]); + } +$$ + + +$$class_handler(handleExternalDisco, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogError(@"External service disco query to '%@' returned an error: %@", iqNode.from, [iqNode findFirst:@"error"]); + //[HelperTools postError:NSLocalizedString(@"XMPP External Service Disco Error", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + return; + } + + for(MLXMLNode* service in [iqNode find:@"{urn:xmpp:extdisco:2}services/service"]) + { + if([service check:@"/"] || [service check:@"/"]) + { + NSMutableDictionary* info = [NSMutableDictionary dictionaryWithDictionary:@{@"directoryJid": iqNode.from}]; + [info addEntriesFromDictionary:[service findFirst:@"/@@"]]; + [account.connectionProperties.discoveredStunTurnServers addObject:info]; + } + } +$$ + +//entity caps of some contact +$$class_handler(handleEntityCapsDisco, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + NSMutableArray* identities = [NSMutableArray new]; + for(MLXMLNode* identity in [iqNode find:@"{http://jabber.org/protocol/disco#info}query/identity"]) + [identities addObject:[NSString stringWithFormat:@"%@/%@/%@/%@", [identity findFirst:@"/@category"], [identity findFirst:@"/@type"], ([identity check:@"/@xml:lang"] ? [identity findFirst:@"/@xml:lang"] : @""), ([identity check:@"/@name"] ? [identity findFirst:@"/@name"] : @"")]]; + NSSet* features = [NSSet setWithArray:[iqNode find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; + NSArray* forms = [iqNode find:@"{http://jabber.org/protocol/disco#info}query/{jabber:x:data}x"]; + NSString* ver = [HelperTools getEntityCapsHashForIdentities:identities andFeatures:features andForms:forms]; + [[DataLayer sharedInstance] setCaps:features forVer:ver onAccountID:account.accountID]; + [account markCapsQueryCompleteFor:ver]; + + //send out kMonalContactRefresh notification + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": [MLContact createContactFromJid:iqNode.fromUser andAccountID:account.accountID] + }]; +$$ + +$$class_handler(handleMamPrefs, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogError(@"MAM prefs query returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:NSLocalizedString(@"XMPP mam preferences error", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + return; + } + if([iqNode check:@"{urn:xmpp:mam:2}prefs@default"]) + [[MLNotificationQueue currentQueue] postNotificationName:kMLMAMPref object:self userInfo:@{@"mamPref": [iqNode findFirst:@"{urn:xmpp:mam:2}prefs@default"]}]; + else + { + DDLogError(@"MAM prefs query returned unexpected result: %@", iqNode); + [HelperTools postError:NSLocalizedString(@"Unexpected mam preferences result", @"") withNode:nil andAccount:account andIsSevere:NO]; + } +$$ + +$$class_handler(handleSetMamPrefs, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogError(@"Setting MAM prefs returned an error, ignoring: %@", [iqNode findFirst:@"error"]); + return; + } +$$ + +$$class_handler(handlePushEnabled, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, selectedPushServer)) + if([iqNode check:@"/"]) + { + DDLogError(@"Enabling push returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:NSLocalizedString(@"Error registering push", @"") withNode:iqNode andAccount:account andIsSevere:YES]; + account.connectionProperties.pushEnabled = NO; + return; + } + // save used push server to db + [[DataLayer sharedInstance] updateUsedPushServer:selectedPushServer forAccount:account.accountID]; + DDLogInfo(@"Push is enabled now"); + account.connectionProperties.pushEnabled = YES; +$$ + +$$class_handler(handleBlocklist, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if(![account.connectionProperties.serverDiscoFeatures containsObject:@"urn:xmpp:blocking"]) + { + DDLogWarn(@"Ignoring blocklist update, server does not announce support for blocking!"); + return; + } + + if([iqNode check:@"/"]) + { + DDLogError(@"Blocklist fetch returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:NSLocalizedString(@"Failed to load blocklist", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + return; + } + + if([iqNode check:@"{urn:xmpp:blocking}blocklist"]) + { + NSMutableSet* blockedJids = [[NSMutableSet alloc] init]; + for(NSDictionary* item in [iqNode find:@"{urn:xmpp:blocking}blocklist/item@@"]) + if(item && item[@"jid"]) + [blockedJids addObject:item[@"jid"]]; + [account updateLocalBlocklistCache:blockedJids]; + // notify the views + [[MLNotificationQueue currentQueue] postNotificationName:kMonalBlockListRefresh object:account userInfo:@{@"accountID": account.accountID}]; + } +$$ + +$$class_handler(handleBlocked, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, blockedJid)) + if(![account.connectionProperties.serverDiscoFeatures containsObject:@"urn:xmpp:blocking"]) + { + DDLogWarn(@"Ignoring block result, server does not announce support for blocking!"); + return; + } + + if([iqNode check:@"/"]) + { + DDLogError(@"Blocking returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"Failed to block contact %@", @""), blockedJid] withNode:iqNode andAccount:account andIsSevere:NO]; + return; + } +$$ + +$$class_handler(handleVersionResponse, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + NSString* iqAppName = [iqNode findFirst:@"{jabber:iq:version}query/name#"]; + NSString* iqAppVersion = [iqNode findFirst:@"{jabber:iq:version}query/version#"]; + NSString* iqPlatformOS = [iqNode findFirst:@"{jabber:iq:version}query/os#"]; + + //server version info is the only case where there will be no resource --> return here + if([iqNode.fromUser isEqualToString:account.connectionProperties.identity.domain]) + { + account.connectionProperties.serverVersion = [[MLContactSoftwareVersionInfo alloc] initWithJid:iqNode.fromUser andRessource:iqNode.fromResource andAppName:iqAppName andAppVersion:iqAppVersion andPlatformOS:iqPlatformOS andLastInteraction:[NSDate date]]; + return; + } + + DDLogVerbose(@"Updating software version info for %@", iqNode.from); + NSDate* lastInteraction = [[DataLayer sharedInstance] lastInteractionOfJid:iqNode.fromUser andResource:iqNode.fromResource forAccountID:account.accountID]; + MLContactSoftwareVersionInfo* newSoftwareVersionInfo = [[MLContactSoftwareVersionInfo alloc] initWithJid:iqNode.fromUser andRessource:iqNode.fromResource andAppName:iqAppName andAppVersion:iqAppVersion andPlatformOS:iqPlatformOS andLastInteraction:lastInteraction]; + + [[DataLayer sharedInstance] setSoftwareVersionInfoForContact:iqNode.fromUser + resource:iqNode.fromResource + andAccount:account.accountID + withSoftwareInfo:newSoftwareVersionInfo]; + + [[MLNotificationQueue currentQueue] postNotificationName:kMonalXmppUserSoftWareVersionRefresh + object:account + userInfo:@{@"versionInfo": newSoftwareVersionInfo}]; +$$ + +$$class_handler(handleModerationResponse, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(MLMessage*, msg)) + [msg updateWithMessage:[[DataLayer sharedInstance] messageForHistoryID:msg.messageDBId]]; //make sure our msg is up to date + if([iqNode check:@"/"]) + { + DDLogError(@"Moderating message %@ returned an error: %@", msg, [iqNode findFirst:@"error"]); + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"Failed to moderate message in group/channel '%@'", @""), iqNode.fromUser] withNode:iqNode andAccount:account andIsSevere:YES]; + return; + } + + DDLogInfo(@"Successfully moderated message in muc: %@", msg); + [[DataLayer sharedInstance] retractMessageHistory:msg.messageDBId]; + + //update ui + DDLogInfo(@"Sending out kMonalDeletedMessageNotice notification for historyId %@", msg.messageDBId); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDeletedMessageNotice object:account userInfo:@{ + @"message": msg, + @"historyId": msg.messageDBId, + @"contact": msg.contact, + }]; + + //update unread count in active chats list + [msg.contact updateUnreadCount]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": msg.contact, + }]; +$$ + +#ifdef IS_QUICKSY +$$class_handler(handleQuicksyPhoneBook, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSDictionary*, numbers)) + if([iqNode check:@"/"]) + { + DDLogError(@"Quicksy phonebook synchronize returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:NSLocalizedString(@"Failed to synchronize phonebook", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + return; + } + + for(MLXMLNode* entry in [iqNode find:@"{im.quicksy.synchronization:0}phone-book/entry"]) + { + NSString* nick = numbers[[entry findFirst:@"/@number"]]; + for(NSString* jid in [entry find:@"jid#"]) + { + DDLogDebug(@"Adding '%@' with nick '%@' to local roster...", jid, nick); + [[DataLayer sharedInstance] addContact:jid forAccount:account.accountID nickname:nick]; +#ifndef DISABLE_OMEMO + // Request omemo devicelist + [account.omemo subscribeAndFetchDevicelistIfNoSessionExistsForJid:jid]; +#endif// DISABLE_OMEMO + + } + } +$$ +#endif + ++(void) respondWithErrorTo:(XMPPIQ*) iqNode onAccount:(xmpp*) account +{ + XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode]; + [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"cancel"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"service-unavailable" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"], + ] andData:nil]]; + [account send:errorIq]; +} + +@end diff --git a/Monal/Classes/MLImageManager.h b/Monal/Classes/MLImageManager.h new file mode 100644 index 0000000..8648742 --- /dev/null +++ b/Monal/Classes/MLImageManager.h @@ -0,0 +1,52 @@ +// +// MLImageManager.h +// Monal +// +// Created by Anurodh Pokharel on 8/16/13. +// +// + +#import + +@import UIKit; +@class MLContact; + +@interface MLImageManager : NSObject + +/** + chatview inbound background image + */ +@property (nonatomic, strong) UIImage* _Nullable inboundImage; +/** + chatview outbound background image + */ +@property (nonatomic, strong) UIImage* _Nullable outboundImage; + + ++(MLImageManager* _Nonnull) sharedInstance; +-(void) cleanupHashes; +-(void) removeAllIcons; + +/** + Takes the string from the xmpp icon vcard info and stores it in an appropropriate place. + */ +-(void) setIconForContact:(MLContact* _Nonnull) contact WithData:(NSData* _Nullable) data ; + +/** + retrieves a uiimage for the icon. returns noicon.png if nothing is found. never returns nil. + */ +-(BOOL) hasIconForContact:(MLContact* _Nonnull) contact; +-(UIImage* _Nullable) getIconForContact:(MLContact* _Nonnull) contact withCompletion:(void (^_Nullable)(UIImage *_Nullable))completion; +-(UIImage* _Nullable) getIconForContact:(MLContact* _Nonnull) contact; ++(UIImage* _Nonnull) circularImage:(UIImage* _Nonnull) image; + +-(void) saveBackgroundImageData:(NSData* _Nullable) data forContact:(MLContact* _Nullable) contact; +-(UIImage* _Nullable) getBackgroundFor:(MLContact* _Nullable) contact; + +/** + Purge cache in the event of a memory warning + */ +-(void) purgeCache; +-(void) purgeCacheForContact:(NSString* _Nonnull) contact andAccount:(NSNumber* _Nonnull) accountID; + +@end diff --git a/Monal/Classes/MLImageManager.m b/Monal/Classes/MLImageManager.m new file mode 100644 index 0000000..f429bfb --- /dev/null +++ b/Monal/Classes/MLImageManager.m @@ -0,0 +1,416 @@ +// +// MLImageManager.m +// Monal +// +// Created by Anurodh Pokharel on 8/16/13. +// +// + +#import "MLImageManager.h" +#import "MLXMPPManager.h" +#import "HelperTools.h" +#import "DataLayer.h" +#import "AESGcm.h" +#import "UIColor+Extension.h" + + +@interface MLImageManager() +@property (nonatomic, strong) NSCache* iconCache; +@property (nonatomic, strong) NSString* documentsDirectory; +@property (nonatomic, strong) NSCache* backgroundCache; +@end + +@implementation MLImageManager + +#pragma mark initilization + ++(MLImageManager*) sharedInstance +{ + static dispatch_once_t once; + static MLImageManager* sharedInstance; + dispatch_once(&once, ^{ + DDLogVerbose(@"Creating shared image manager instance..."); + sharedInstance = [MLImageManager new]; + }); + return sharedInstance; +} + +//this mehod should *only* be used in the mainapp due to memory requirements for large images ++(UIImage*) circularImage:(UIImage*) image +{ + return [[[UIGraphicsImageRenderer alloc] initWithSize:image.size] imageWithActions:^(UIGraphicsImageRendererContext* _Nonnull rendererContext) { + UIBezierPath* clipPath = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0, 0, image.size.width, image.size.height)]; + [clipPath addClip]; + + //Flip coordinates before drawing image as UIKit and CoreGraphics have inverted coordinate system + CGContextTranslateCTM(rendererContext.CGContext, 0, image.size.height); + CGContextScaleCTM(rendererContext.CGContext, 1, -1); + + CGContextDrawImage(rendererContext.CGContext, CGRectMake(0, 0, image.size.width, image.size.height), image.CGImage); + }]; +} + ++(UIImage*) image:(UIImage*) image withMucOverlay:(UIImage*) overlay +{ + UIGraphicsImageRendererFormat* format = [UIGraphicsImageRendererFormat new]; + format.opaque = NO; + format.preferredRange = UIGraphicsImageRendererFormatRangeStandard; + format.scale = 1.0; + CGRect drawRect = CGRectMake(0, 0, image.size.width, image.size.height); + CGFloat overlaySize = (float)(image.size.width / 3); + UIGraphicsImageRenderer* renderer = [[UIGraphicsImageRenderer alloc] initWithSize:drawRect.size format:format]; + return [renderer imageWithActions:^(UIGraphicsImageRendererContext* _Nonnull context __unused) { + [image drawInRect:drawRect]; + CGRect overlayRect = CGRectMake(0, //renderer.format.bounds.size.width - overlaySize + 0, //renderer.format.bounds.size.height - overlaySize + overlaySize, + overlaySize); + [overlay drawInRect:overlayRect]; + }]; +} + +-(id) init +{ + self = [super init]; + self.iconCache = [NSCache new]; + self.backgroundCache = [NSCache new]; + + NSFileManager* fileManager = [NSFileManager defaultManager]; + + self.documentsDirectory = [[HelperTools getContainerURLForPathComponents:@[]] path]; + + NSString* writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"imagecache"]; + [fileManager createDirectoryAtPath:writablePath withIntermediateDirectories:YES attributes:nil error:nil]; + [HelperTools configureFileProtectionFor:writablePath]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMemoryPressureNotification) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; + + return self; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(void) handleMemoryPressureNotification +{ + DDLogVerbose(@"Removing all objects in avatar cache due to memory pressure..."); + [self purgeCache]; +} + +#pragma mark cache + +-(void) purgeCache +{ + [self.iconCache removeAllObjects]; + [self.backgroundCache removeAllObjects]; +} + +-(void) purgeCacheForContact:(NSString*) contact andAccount:(NSNumber*) accountID +{ + [self.iconCache removeObjectForKey:[NSString stringWithFormat:@"%@_%@", accountID, contact]]; + [self resetCachedBackgroundImageForContact:[MLContact createContactFromJid:contact andAccountID:accountID]]; +} + +-(void) cleanupHashes +{ + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSArray* contactList = [[DataLayer sharedInstance] contactList]; + + for(MLContact* contact in contactList) + { + NSString* writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"buddyicons"]; + writablePath = [writablePath stringByAppendingPathComponent:contact.accountID.stringValue]; + writablePath = [writablePath stringByAppendingPathComponent:[self fileNameforContact:contact]]; + NSString* hash = [[DataLayer sharedInstance] getAvatarHashForContact:contact.contactJid andAccount:contact.accountID]; + BOOL hasHash = ![@"" isEqualToString:hash]; + + if(hasHash && ![fileManager isReadableFileAtPath:writablePath]) + { + DDLogDebug(@"Deleting orphan hash '%@' of contact: %@", hash, contact); + //delete avatar hash from db if the file containing our image data vanished + [[DataLayer sharedInstance] setAvatarHash:@"" forContact:contact.contactJid andAccount:contact.accountID]; + } + + if(!hasHash && [fileManager isReadableFileAtPath:writablePath]) + { + DDLogDebug(@"Deleting orphan avatar file '%@' of contact: %@", writablePath, contact); + NSError* error; + [fileManager removeItemAtPath:writablePath error:&error]; + if(error) + DDLogError(@"Error deleting orphan avatar file: %@", error); + } + } +} + +-(void) removeAllIcons +{ + NSError* error; + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSString* writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"buddyicons"]; + [fileManager removeItemAtPath:writablePath error:&error]; + if(error) + DDLogError(@"Got error while trying to delete all avatar files: %@", error); +} + +#pragma mark chat bubbles + +-(UIImage*) inboundImage +{ + if(_inboundImage) + return _inboundImage; + _inboundImage = [[UIImage imageNamed:@"incoming"] resizableImageWithCapInsets:UIEdgeInsetsMake(6, 6, 6, 6)]; + return _inboundImage; + +} + +-(UIImage*) outboundImage +{ + if (_outboundImage) + return _outboundImage; + _outboundImage = [[UIImage imageNamed:@"outgoing"] resizableImageWithCapInsets:UIEdgeInsetsMake(6, 6, 6, 6)]; + return _outboundImage; +} + +#pragma mark user icons + +-(UIImage*) generateDummyIconForContact:(MLContact*) contact +{ + NSString* contactLetter; + + if(contact.isSelfChat) + { + xmpp* account = contact.account; + contactLetter = [[[MLContact ownDisplayNameForAccount:account] substringToIndex:1] uppercaseString]; + } + else + contactLetter = [[[contact contactDisplayName] substringToIndex:1] uppercaseString]; + + UIColor* background = [HelperTools generateColorFromJid:contact.contactJid]; + UIColor* foreground = [UIColor blackColor]; + if(![background isLightColor]) + foreground = [UIColor whiteColor]; + + CGRect drawRect = CGRectMake(0, 0, 200, 200); + UIGraphicsImageRenderer* renderer = [[UIGraphicsImageRenderer alloc] initWithSize:drawRect.size]; + return [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull context) { + //make sure our image is circular + [[UIBezierPath bezierPathWithOvalInRect:drawRect] addClip]; + + //fill the background of our image + [background setFill]; + [context fillRect:renderer.format.bounds]; + + //draw letter in the middleof our image + NSMutableParagraphStyle* paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; + paragraphStyle.alignment = NSTextAlignmentCenter; + NSDictionary* attributes = @{ + NSFontAttributeName: [[UIFont preferredFontForTextStyle:UIFontTextStyleLargeTitle] fontWithSize:(unsigned int)(drawRect.size.height / 1.666)], + NSForegroundColorAttributeName: foreground, + NSParagraphStyleAttributeName: paragraphStyle + }; + CGSize textSize = [contactLetter sizeWithAttributes:attributes]; + CGRect textRect = CGRectMake(floorf((float)(renderer.format.bounds.size.width - textSize.width) / 2), + floorf((float)(renderer.format.bounds.size.height - textSize.height) / 2), + textSize.width, + textSize.height); + [contactLetter drawInRect:textRect withAttributes:attributes]; + }]; +} + +-(NSString*) fileNameforContact:(MLContact*) contact +{ + return [NSString stringWithFormat:@"%@_%@.png", contact.accountID.stringValue, [contact.contactJid lowercaseString]];; +} + +-(void) setIconForContact:(MLContact*) contact WithData:(NSData* _Nullable) data +{ + //documents directory/buddyicons/account no/contact + + NSString* filename = [self fileNameforContact:contact]; + + NSFileManager* fileManager = [NSFileManager defaultManager]; + + NSString *writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"buddyicons"]; + writablePath = [writablePath stringByAppendingPathComponent:contact.accountID.stringValue]; + NSError* error; + [fileManager createDirectoryAtPath:writablePath withIntermediateDirectories:YES attributes:nil error:&error]; + [HelperTools configureFileProtectionFor:writablePath]; + writablePath = [writablePath stringByAppendingPathComponent:filename]; + + if([fileManager fileExistsAtPath:writablePath]) + [fileManager removeItemAtPath:writablePath error:nil]; + + if(data) + { + if([data writeToFile:writablePath atomically:NO]) + { + [HelperTools configureFileProtectionFor:writablePath]; + DDLogVerbose(@"wrote image to file: %@", writablePath); + } + else + DDLogError(@"failed to write image to file: %@", writablePath); + } + + //remove from cache if its there + [self.iconCache removeObjectForKey:[NSString stringWithFormat:@"%@_%@", contact.accountID, contact]]; + +} + +-(BOOL) hasIconForContact:(MLContact*) contact +{ + NSString* filename = [self fileNameforContact:contact]; + + NSString* writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"buddyicons"]; + writablePath = [writablePath stringByAppendingPathComponent:contact.accountID.stringValue]; + writablePath = [writablePath stringByAppendingPathComponent:filename]; + + DDLogVerbose(@"Checking avatar image at: %@", writablePath); + return [UIImage imageWithContentsOfFile:writablePath] != nil; +} + +-(UIImage*) getIconForContact:(MLContact*) contact +{ + return [self getIconForContact:contact withCompletion:nil]; +} + +-(UIImage*) getIconForContact:(MLContact*) contact withCompletion:(void (^)(UIImage *))completion +{ + NSString* filename = [self fileNameforContact:contact]; + + __block UIImage* toreturn = nil; + //get filname from DB + NSString* cacheKey = [NSString stringWithFormat:@"%@_%@", contact.accountID, contact.contactJid]; + + //check cache + toreturn = [self.iconCache objectForKey:cacheKey]; + if(!toreturn) + { + NSString* writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"buddyicons"]; + writablePath = [writablePath stringByAppendingPathComponent:contact.accountID.stringValue]; + writablePath = [writablePath stringByAppendingPathComponent:filename]; + + DDLogVerbose(@"Loading avatar image at: %@", writablePath); + UIImage* savedImage = [UIImage imageWithContentsOfFile:writablePath]; + if(savedImage) + toreturn = savedImage; + DDLogVerbose(@"Loaded image: %@", toreturn); + + if(toreturn == nil) //return default avatar + { + DDLogVerbose(@"Using/generating dummy icon for contact: %@", contact); + if(contact.isMuc) + { + if([kMucTypeChannel isEqualToString:contact.mucType]) + toreturn = [MLImageManager circularImage:[UIImage imageNamed:@"noicon_channel" inBundle:nil compatibleWithTraitCollection:nil]]; + else + toreturn = [MLImageManager circularImage:[UIImage imageNamed:@"noicon_muc" inBundle:nil compatibleWithTraitCollection:nil]]; + } + else + toreturn = [self generateDummyIconForContact:contact]; + } + else if(contact.isMuc) //add group indicator overlay for non-default muc avatar + { + UIImage* overlay = nil; + if([kMucTypeChannel isEqualToString:contact.mucType]) + overlay = [MLImageManager circularImage:[UIImage imageNamed:@"noicon_channel" inBundle:nil compatibleWithTraitCollection:nil]]; + else + overlay = [MLImageManager circularImage:[UIImage imageNamed:@"noicon_muc" inBundle:nil compatibleWithTraitCollection:nil]]; + if(overlay) + toreturn = [MLImageManager image:toreturn withMucOverlay:overlay]; + } + + //uiimage is cached if avaialable, but only if not in appex due to memory limits therein + if(toreturn && ![HelperTools isAppExtension]) + [self.iconCache setObject:toreturn forKey:cacheKey]; + + if(completion) + dispatch_async(dispatch_get_main_queue(), ^{ + completion(toreturn); + }); + } + else if(completion) + dispatch_async(dispatch_get_main_queue(), ^{ + completion(toreturn); + }); + return toreturn; +} + + +-(void) saveBackgroundImageData:(NSData* _Nullable) data forContact:(MLContact* _Nullable) contact +{ + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSString* writablePath; + if(contact != nil) + { + NSString* filename = [self fileNameforContact:contact]; + writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"backgrounds"]; + + [fileManager createDirectoryAtPath:writablePath withIntermediateDirectories:YES attributes:nil error:nil]; + [HelperTools configureFileProtectionFor:writablePath]; + + writablePath = [writablePath stringByAppendingPathComponent:filename]; + if([fileManager fileExistsAtPath:writablePath]) + [fileManager removeItemAtPath:writablePath error:nil]; + } + else + { + writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"background.jpg"]; + if([fileManager fileExistsAtPath:writablePath]) + [fileManager removeItemAtPath:writablePath error:nil]; + } + [self resetCachedBackgroundImageForContact:contact]; + + //file was deleted above, just don't create it again + if(data != nil) + { + DDLogVerbose(@"Writing background image data %@ for %@ to '%@'...", data, contact, writablePath); + [data writeToFile:writablePath atomically:YES]; + [HelperTools configureFileProtectionFor:writablePath]; + } + + //don't queue this notification because it should be handled immediately + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalBackgroundChanged object:contact]; +} + +-(UIImage* _Nullable) getBackgroundFor:(MLContact* _Nullable) contact +{ + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSString* filename = @"background.jpg"; + if(contact != nil) + filename = [self fileNameforContact:contact]; + UIImage* img = [self.backgroundCache objectForKey:filename]; + if(img != nil) + return img; + + NSString* writablePath; + if(contact != nil) + { + writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"backgrounds"]; + writablePath = [writablePath stringByAppendingPathComponent:filename]; + if(![fileManager fileExistsAtPath:writablePath]) + return nil; + } + else + { + writablePath = [self.documentsDirectory stringByAppendingPathComponent:@"background.jpg"]; + if(![fileManager fileExistsAtPath:writablePath]) + return nil; + } + DDLogVerbose(@"Loading background image for %@ from '%@'...", contact, writablePath); + img = [UIImage imageWithContentsOfFile:writablePath]; + DDLogVerbose(@"Got image: %@", img); + [self.backgroundCache setObject:img forKey:filename]; + return img; +} + +-(void) resetCachedBackgroundImageForContact:(MLContact* _Nullable) contact +{ + NSString* filename = @"background.jpg"; + if(contact != nil) + filename = [self fileNameforContact:contact]; + [self.backgroundCache removeObjectForKey:filename]; +} + +@end diff --git a/Monal/Classes/MLLinkCell.h b/Monal/Classes/MLLinkCell.h new file mode 100644 index 0000000..941cf4d --- /dev/null +++ b/Monal/Classes/MLLinkCell.h @@ -0,0 +1,24 @@ +// +// MLLinkCell.h +// Monal +// +// Created by Anurodh Pokharel on 10/29/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import "MLBaseCell.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLLinkCell : MLBaseCell +@property (nonatomic, strong) IBOutlet UILabel* messageTitle; +@property (nonatomic, strong) IBOutlet UIImageView* previewImage; +@property (nonatomic, strong) NSURL* imageUrl; + +-(void) loadImageWithCompletion:(void (^)(void))completion; + +-(void) openlink: (id) sender; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLLinkCell.m b/Monal/Classes/MLLinkCell.m new file mode 100644 index 0000000..8f00c9a --- /dev/null +++ b/Monal/Classes/MLLinkCell.m @@ -0,0 +1,90 @@ +// +// MLLinkCell.m +// Monal +// +// Created by Anurodh Pokharel on 10/29/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import "MLLinkCell.h" +#import "UIImageView+WebCache.h" +#import "MonalAppDelegate.h" +#import "HelperTools.h" + +@import SafariServices; + + +@implementation MLLinkCell + +- (void)awakeFromNib { + [super awakeFromNib]; + // Initialization code + self.bubbleView.layer.cornerRadius=16.0f; + self.bubbleView.clipsToBounds=YES; +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated { + [super setSelected:selected animated:animated]; + + // Configure the view for the selected state +} + + + +-(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]; + } +} + +-(BOOL) canPerformAction:(SEL)action withSender:(id)sender +{ + if(action == @selector(openlink:)) + { + if(self.link) + return YES; + } + return (action == @selector(copy:)) ; +} + +-(void) copy:(id)sender { + UIPasteboard *pboard = [UIPasteboard generalPasteboard]; + pboard.string =self.link; +} + + +-(void) loadImageWithCompletion:(void (^)(void))completion +{ + if(self.imageUrl) { + [self.previewImage sd_setImageWithURL:self.imageUrl completed:^(UIImage *image __unused, NSError *error, SDImageCacheType cacheType __unused, NSURL *imageURL __unused) { + if(error) { + self.previewImage.image=nil; + } + + if(completion) completion(); + }]; + } else { + self.previewImage.image=nil; + if(completion) completion(); + } +} + +-(void)prepareForReuse +{ + [super prepareForReuse]; + self.messageTitle.text=nil; + self.imageUrl=[NSURL URLWithString:@""]; + self.previewImage.image=nil; +} + +@end diff --git a/Monal/Classes/MLLogFileManager.h b/Monal/Classes/MLLogFileManager.h new file mode 100755 index 0000000..5982f80 --- /dev/null +++ b/Monal/Classes/MLLogFileManager.h @@ -0,0 +1,21 @@ +// +// MLLogFileManager.h +// monalxmpp +// +// Created by Thilo Molitor on 21.07.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MLLogFileManager : DDLogFileManagerDefault + +-(instancetype) initWithLogsDirectory:(NSString* _Nullable) dir; +-(NSString*) newLogFileName; +-(BOOL) isLogFile:(NSString*) fileName; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLLogFileManager.m b/Monal/Classes/MLLogFileManager.m new file mode 100755 index 0000000..1fafc33 --- /dev/null +++ b/Monal/Classes/MLLogFileManager.m @@ -0,0 +1,85 @@ +// +// MLLogFileManager.m +// monalxmpp +// +// Created by Thilo Molitor on 21.07.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "HelperTools.h" +#import "MLLogFileManager.h" + +@interface DDFileLogRawlogMessageSerializer : NSObject +@end + +@interface MLLogFileManager () +@end + +static NSString* appName = @"Monal"; + +@implementation DDFileLogRawlogMessageSerializer + +-(NSData*) dataForString:(NSString*) string originatingFromMessage:(DDLogMessage*) logMessage +{ + static uint64_t counter = 0; + + if(logMessage == nil) + { + NSLog(@"Error: logMessage should never be nil when calling dataForString:originatingFromMessage. Given log string: %@", string); + return [NSData new]; //return empty data, e.g. write nothing + } + + //encode log message + NSError* error; + NSData* rawData = [HelperTools convertLogmessageToJsonData:logMessage counter:&counter andError:&error]; + if(error != nil || rawData == nil) + { + NSLog(@"Error jsonifying log message: %@, logMessage: %@", error, logMessage); + return [NSData new]; //return empty data, e.g. write nothing + } + + //add 32bit length prefix + NSAssert(rawData.length < (NSUInteger)1<<26, @"LogMessage is longer than 1<<26 bytes!"); + uint32_t length = CFSwapInt32HostToBig((uint32_t)rawData.length); + NSMutableData* data = [[NSMutableData alloc] initWithBytes:&length length:sizeof(length)]; + [data appendData:rawData]; + + //return length_prefix + json_encoded_data + return data; +} + +@end + +@implementation MLLogFileManager + +-(instancetype) initWithLogsDirectory:(NSString* _Nullable) dir +{ + self = [super initWithLogsDirectory:dir]; + self.logMessageSerializer = [DDFileLogRawlogMessageSerializer new]; + return self; +} + +-(NSString*) newLogFileName +{ + NSDateFormatter* dateFormatter = [NSDateFormatter new]; + [dateFormatter setLocale:[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]]; + [dateFormatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]]; + [dateFormatter setDateFormat: @"yyyy'-'MM'-'dd'--'HH'-'mm'-'ss'-'SSS'"]; + + NSString* formattedDate = [dateFormatter stringFromDate:[NSDate date]]; + NSString* logfile = [NSString stringWithFormat:@"%@ %@.rawlog", appName, formattedDate]; + [HelperTools updateCurrentLogfilePath:logfile]; + return logfile; +} + +-(BOOL) isLogFile:(NSString*) fileName +{ + // We need to add a space to the name as otherwise we could match applications that have the name prefix. + BOOL hasProperPrefix = [fileName hasPrefix:[appName stringByAppendingString:@" "]]; + BOOL hasProperSuffix = [fileName hasSuffix:@".rawlog"]; + + return (hasProperPrefix && hasProperSuffix); +} + +@end diff --git a/Monal/Classes/MLMAMPrefTableViewController.h b/Monal/Classes/MLMAMPrefTableViewController.h new file mode 100644 index 0000000..c589fbd --- /dev/null +++ b/Monal/Classes/MLMAMPrefTableViewController.h @@ -0,0 +1,16 @@ +// +// MLMAMPrefTableViewController.h +// Monal +// +// Created by Anurodh Pokharel on 5/17/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import +#import "xmpp.h" + +@interface MLMAMPrefTableViewController : UITableViewController + +@property (nonatomic, weak) xmpp *xmppAccount; + +@end diff --git a/Monal/Classes/MLMAMPrefTableViewController.m b/Monal/Classes/MLMAMPrefTableViewController.m new file mode 100644 index 0000000..f296fc6 --- /dev/null +++ b/Monal/Classes/MLMAMPrefTableViewController.m @@ -0,0 +1,110 @@ +// +// MLMAMPrefTableViewController.m +// Monal +// +// Created by Anurodh Pokharel on 5/17/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import "MLMAMPrefTableViewController.h" + +@interface MLMAMPrefTableViewController () +@property (nonatomic, strong) NSMutableArray* mamPref; +@property (nonatomic, strong) NSString* currentPref; +@end + +@implementation MLMAMPrefTableViewController + +-(void) viewDidLoad +{ + [super viewDidLoad]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updatePrefs:) name:kMLMAMPref object:nil]; + [self.xmppAccount getMAMPrefs]; +} + +-(void) didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +-(void) viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + self.navigationItem.title = self.xmppAccount.connectionProperties.identity.domain; + self.mamPref = [NSMutableArray new]; + [self.mamPref addObject:@{@"Title":NSLocalizedString(@"Always archive", @""), @"Description":NSLocalizedString(@"All messages are archived by default.", @""), @"value":@"always"}]; + [self.mamPref addObject:@{@"Title":NSLocalizedString(@"Never archive", @""), @"Description":NSLocalizedString(@"Messages never archived by default.", @""), @"value":@"never"}]; + [self.mamPref addObject:@{@"Title":NSLocalizedString(@"Only contacts", @""), @"Description":NSLocalizedString(@"Archive only if the contact is in contact list.", @""), @"value":@"roster"}]; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + + +-(void) updatePrefs:(NSNotification *) notification +{ + dispatch_async(dispatch_get_main_queue(), ^{ + NSDictionary* dic = (NSDictionary*)notification.userInfo; + self.currentPref = [dic objectForKey:@"mamPref"]; + [self.tableView reloadData]; + }); +} + +#pragma mark - Table view data source + +-(NSInteger) numberOfSectionsInTableView:(UITableView *)tableView +{ + return 1; +} + +-(NSInteger) tableView:(UITableView*) tableView numberOfRowsInSection:(NSInteger) section +{ + return self.mamPref.count; +} + + +-(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath +{ + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:@"serverCell" forIndexPath:indexPath]; + NSDictionary* dic = [self.mamPref objectAtIndex:indexPath.row]; + cell.textLabel.text = dic[@"Title"]; + cell.detailTextLabel.text = dic[@"Description"]; + + if([dic[@"value"] isEqualToString:self.currentPref]) + cell.accessoryType = UITableViewCellAccessoryCheckmark; + else + cell.accessoryType = UITableViewCellAccessoryNone; + + return cell; +} + +-(void) tableView:(UITableView*) tableView didSelectRowAtIndexPath:(NSIndexPath*) indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:NO]; + switch(indexPath.row) + { + case 0: + self.currentPref = @"always"; + break; + case 1: + self.currentPref = @"never"; + break; + case 2: + self.currentPref = @"roster"; + break; + } + [self.xmppAccount setMAMPrefs:self.currentPref]; + [self.tableView reloadData]; +} + + +-(NSString*) tableView:(UITableView*) tableView titleForHeaderInSection:(NSInteger) section +{ + return NSLocalizedString(@"Select Message Archive Management (MAM) Preferences ", @""); +} + +@end diff --git a/Monal/Classes/MLMessage.h b/Monal/Classes/MLMessage.h new file mode 100644 index 0000000..5a2bc44 --- /dev/null +++ b/Monal/Classes/MLMessage.h @@ -0,0 +1,134 @@ +// +// MLMessage.h +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class MLContact; + +/** + message object intended to be passed around and eventually used to render + */ +@interface MLMessage : NSObject + ++(BOOL) supportsSecureCoding; + +@property (readonly) NSString* id; //for Identifiable protocol + +/** + account number in the database should be an integer + */ +@property (nonatomic, copy) NSNumber* accountID; + +/** + jid of the contact that this msg corresponds to + */ +@property (nonatomic, copy) NSString* buddyName; + +/** + indicating if the message was send from buddyName + */ +@property (nonatomic, assign) BOOL inbound; + +/** + The message's local unique identifier + */ +@property (nonatomic, copy) NSString* messageId; + +/** + The id for the message as provided by the xmpp server + */ +@property (nonatomic, copy) NSString* stanzaId; + +/** +The of the message in the DB , should be int + */ +@property (nonatomic, copy) NSNumber* messageDBId; + +/** + Actual sender will differ from the "from" when in a group chat + */ +@property (nonatomic, copy) NSString* actualFrom; +@property (nonatomic, assign) BOOL isMuc; +@property (nonatomic, readonly) NSString* contactDisplayName; + +@property (nonatomic, copy) NSString* messageType; +@property (nonatomic, copy) NSString* mucType; +@property (nonatomic, copy) NSString* participantJid; + +@property (nonatomic, copy) NSString* filetransferMimeType; +@property (nonatomic, copy) NSNumber* filetransferSize; + +@property (nonatomic, copy) NSString* messageText; + +@property (nonatomic, assign) BOOL retracted; + +/** + If the text was parsed into a URL. For message type url + */ +@property (nonatomic, copy) NSURL* url; + +/** + path to preview image for image type + */ +@property (nonatomic, copy) NSURL* previewImage; +@property (nonatomic, copy) NSString* previewText; + +/** + for message type status. The MUC subeject + */ +@property (nonatomic, copy) NSString* groupSubject; +@property (nonatomic, copy) NSDate* timestamp; + +/* + usually used to indicate if the message was encrypted on the wire, not in this payload + */ +@property (nonatomic, assign) BOOL encrypted; + +/* + whether the text was acked by the server + */ +@property (nonatomic, assign) BOOL hasBeenSent; + +/* + Whether a message was recieved by the device on the other end + */ +@property (nonatomic, assign) BOOL hasBeenReceived; +@property (nonatomic, assign) BOOL hasBeenDisplayed; + +/** + values only set if in a response the message was marked as error. + if hasBeenReceived is true, these should be ignored + */ +@property (nonatomic, copy) NSString* errorType; +@property (nonatomic, copy) NSString* errorReason; + +/* + the message has not been marked as read in the db + */ +@property (nonatomic, assign) BOOL unread; +@property (nonatomic, assign) BOOL displayMarkerWanted; + +/** + Converts a dictonary to a message object Provide a formatter for the format the dates will be in + */ ++(MLMessage*) messageFromDictionary:(NSDictionary*) dic; + +-(void) updateWithMessage:(MLMessage*) msg; +@property (nonatomic, readonly) MLContact* contact; + +-(BOOL) isEqualToContact:(MLContact*) contact; +-(BOOL) isEqualToMessage:(MLMessage*) message; +-(BOOL) isEqual:(id _Nullable) object; + +@property (strong, readonly) NSString* description; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLMessage.m b/Monal/Classes/MLMessage.m new file mode 100644 index 0000000..1230afb --- /dev/null +++ b/Monal/Classes/MLMessage.m @@ -0,0 +1,233 @@ +// +// MLMessage.m +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLMessage.h" +#import "MLContact.h" +#import "MLConstants.h" + +@implementation MLMessage +{ + MLContact* _contact; +} + ++(MLMessage*) messageFromDictionary:(NSDictionary*) dic +{ + MLMessage* message = [MLMessage new]; + message.accountID = [dic objectForKey:@"account_id"]; + + message.buddyName = [dic objectForKey:@"buddy_name"]; + message.inbound = [(NSNumber*)[dic objectForKey:@"inbound"] boolValue]; + message.actualFrom = [dic objectForKey:@"af"]; + message.messageText = [dic objectForKey:@"message"]; + message.isMuc = [(NSNumber*)[dic objectForKey:@"Muc"] boolValue]; + + message.messageId = [dic objectForKey:@"messageid"]; + message.stanzaId = [dic objectForKey:@"stanzaid"]; + message.messageDBId = [dic objectForKey:@"message_history_id"]; + message.timestamp = [dic objectForKey:@"thetime"]; + message.messageType = [dic objectForKey:@"messageType"]; + message.mucType = [dic objectForKey:@"muc_type"]; + message.participantJid = [dic objectForKey:@"participant_jid"]; + + message.hasBeenDisplayed = [(NSNumber*)[dic objectForKey:@"displayed"] boolValue]; + message.hasBeenReceived = [(NSNumber*)[dic objectForKey:@"received"] boolValue]; + message.hasBeenSent = [(NSNumber*)[dic objectForKey:@"sent"] boolValue]; + message.encrypted = [(NSNumber*)[dic objectForKey:@"encrypted"] boolValue]; + + message.unread = [(NSNumber*)[dic objectForKey:@"unread"] boolValue]; + message.displayMarkerWanted = [(NSNumber*)[dic objectForKey:@"displayMarkerWanted"] boolValue]; + + message.previewText = [dic objectForKey:@"previewText"]; + message.previewImage = [NSURL URLWithString:[dic objectForKey:@"previewImage"]]; + + message.errorType = [dic objectForKey:@"errorType"]; + message.errorReason = [dic objectForKey:@"errorReason"]; + + message.filetransferMimeType = [dic objectForKey:@"filetransferMimeType"]; + message.filetransferSize = [dic objectForKey:@"filetransferSize"]; + + message.retracted = [(NSNumber*)[dic objectForKey:@"retracted"] boolValue]; + + return message; +} + ++(BOOL) supportsSecureCoding +{ + return YES; +} + +-(void) encodeWithCoder:(NSCoder*) coder +{ + [coder encodeObject:self.accountID forKey:@"accountID"]; + [coder encodeObject:self.buddyName forKey:@"buddyName"]; + [coder encodeBool:self.inbound forKey:@"inbound"]; + [coder encodeObject:self.actualFrom forKey:@"actualFrom"]; + [coder encodeObject:self.messageText forKey:@"messageText"]; + [coder encodeBool:self.isMuc forKey:@"isMuc"]; + [coder encodeObject:self.messageId forKey:@"messageId"]; + [coder encodeObject:self.stanzaId forKey:@"stanzaId"]; + [coder encodeObject:self.messageDBId forKey:@"messageDBId"]; + [coder encodeObject:self.timestamp forKey:@"timestamp"]; + [coder encodeObject:self.messageType forKey:@"messageType"]; + [coder encodeObject:self.mucType forKey:@"mucType"]; + [coder encodeObject:self.participantJid forKey:@"participantJid"]; + [coder encodeBool:self.hasBeenDisplayed forKey:@"hasBeenDisplayed"]; + [coder encodeBool:self.hasBeenReceived forKey:@"hasBeenReceived"]; + [coder encodeBool:self.hasBeenSent forKey:@"hasBeenSent"]; + [coder encodeBool:self.encrypted forKey:@"encrypted"]; + [coder encodeBool:self.unread forKey:@"unread"]; + [coder encodeBool:self.displayMarkerWanted forKey:@"displayMarkerWanted"]; + [coder encodeObject:self.previewText forKey:@"previewText"]; + [coder encodeObject:self.previewImage forKey:@"previewImage"]; + [coder encodeObject:self.errorType forKey:@"errorType"]; + [coder encodeObject:self.errorReason forKey:@"errorReason"]; + [coder encodeObject:self.filetransferMimeType forKey:@"filetransferMimeType"]; + [coder encodeObject:self.filetransferSize forKey:@"filetransferSize"]; + [coder encodeBool:self.retracted forKey:@"retracted"]; +} + +-(instancetype) initWithCoder:(NSCoder*) coder +{ + self = [self init]; + self.accountID = [coder decodeObjectForKey:@"accountID"]; + self.buddyName = [coder decodeObjectForKey:@"buddyName"]; + self.inbound = [coder decodeBoolForKey:@"inbound"]; + self.actualFrom = [coder decodeObjectForKey:@"actualFrom"]; + self.messageText = [coder decodeObjectForKey:@"messageText"]; + self.isMuc = [coder decodeBoolForKey:@"isMuc"]; + self.messageId = [coder decodeObjectForKey:@"messageId"]; + self.stanzaId = [coder decodeObjectForKey:@"stanzaId"]; + self.messageDBId = [coder decodeObjectForKey:@"messageDBId"]; + self.timestamp = [coder decodeObjectForKey:@"timestamp"]; + self.messageType = [coder decodeObjectForKey:@"messageType"]; + self.mucType = [coder decodeObjectForKey:@"mucType"]; + self.participantJid = [coder decodeObjectForKey:@"participantJid"]; + self.hasBeenDisplayed = [coder decodeBoolForKey:@"hasBeenDisplayed"]; + self.hasBeenReceived = [coder decodeBoolForKey:@"hasBeenReceived"]; + self.hasBeenSent = [coder decodeBoolForKey:@"hasBeenSent"]; + self.encrypted = [coder decodeBoolForKey:@"encrypted"]; + self.unread = [coder decodeBoolForKey:@"unread"]; + self.displayMarkerWanted = [coder decodeBoolForKey:@"displayMarkerWanted"]; + self.previewText = [coder decodeObjectForKey:@"previewText"]; + self.previewImage = [coder decodeObjectForKey:@"previewImage"]; + self.errorType = [coder decodeObjectForKey:@"errorType"]; + self.errorReason = [coder decodeObjectForKey:@"errorReason"]; + self.filetransferMimeType = [coder decodeObjectForKey:@"filetransferMimeType"]; + self.filetransferSize = [coder decodeObjectForKey:@"filetransferSize"]; + self.retracted = [coder decodeBoolForKey:@"retracted"]; + return self; +} + +-(void) updateWithMessage:(MLMessage*) msg +{ + self.accountID = msg.accountID; + self.buddyName = msg.buddyName; + self.inbound = msg.inbound; + self.actualFrom = msg.actualFrom; + self.messageText = msg.messageText; + self.isMuc = msg.isMuc; + self.messageId = msg.messageId; + self.stanzaId = msg.stanzaId; + self.messageDBId = msg.messageDBId; + self.timestamp = msg.timestamp; + self.messageType = msg.messageType; + self.mucType = msg.mucType; + self.participantJid = msg.participantJid; + self.hasBeenDisplayed = msg.hasBeenDisplayed; + self.hasBeenReceived = msg.hasBeenReceived; + self.hasBeenSent = msg.hasBeenSent; + self.encrypted = msg.encrypted; + self.unread = msg.unread; + self.displayMarkerWanted = msg.displayMarkerWanted; + self.previewText = msg.previewText; + self.previewImage = msg.previewImage; + self.errorType = msg.errorType; + self.errorReason = msg.errorReason; + self.filetransferMimeType = msg.filetransferMimeType; + self.filetransferSize = msg.filetransferSize; + self.retracted = msg.retracted; +} + +-(NSString*) contactDisplayName +{ + if(self.isMuc) + { + if([kMucTypeGroup isEqualToString:self.mucType] && self.participantJid) + return [[MLContact createContactFromJid:self.participantJid andAccountID:self.accountID] contactDisplayNameWithFallback:self.actualFrom]; + else + return self.actualFrom; + } + else + return [MLContact createContactFromJid:self.buddyName andAccountID:self.accountID].contactDisplayName; +} + +-(MLContact*) contact +{ + if(self->_contact != nil) + return self->_contact; + return self->_contact = [MLContact createContactFromJid:self.buddyName andAccountID:self.accountID]; +} + +-(BOOL) isEqualToContact:(MLContact*) contact +{ + return contact != nil && + [self.buddyName isEqualToString:contact.contactJid] && + self.accountID.intValue == contact.accountID.intValue; +} + +-(BOOL) isEqualToMessage:(MLMessage*) message +{ + return message != nil && + self.accountID.intValue == message.accountID.intValue && + [self.buddyName isEqualToString:message.buddyName] && + self.inbound == message.inbound && + [self.actualFrom isEqualToString:message.actualFrom] && + ( + // either the stanzaid is equal --> strong same message + // or the message id is equal (could be stanza id or origin id) --> weak same message, if stanza id + [self.stanzaId isEqualToString:message.stanzaId] || + [self.messageId isEqualToString:message.messageId] + ); +} + +-(BOOL) isEqual:(id) object +{ + if(self == object) + return YES; + if([object isKindOfClass:[MLContact class]]) + return [self isEqualToContact:(MLContact*)object]; + if([object isKindOfClass:[MLMessage class]]) + return [self isEqualToMessage:(MLMessage*)object]; + return NO; +} + +-(NSUInteger) hash +{ + return [self.accountID hash] ^ [self.buddyName hash] ^ (self.inbound ? 1 : 0) ^ + [self.actualFrom hash] ^ [self.messageText hash] ^ [self.messageId hash] ^ + [self.stanzaId hash]; +} + +-(NSString*) id +{ + return [NSString stringWithFormat:@"%@|%@", self.accountID, self.messageDBId]; +} + +-(NSString*) description +{ + return [NSString stringWithFormat:@"%@: %@ {%@messageID: %@, stanzaID: %@} --> %@", + self.accountID, + self.participantJid ? self.participantJid : self.buddyName, + self.retracted ? @"retracted " : @"", + self.messageId, + self.stanzaId, + self.messageDBId + ]; +} + +@end diff --git a/Monal/Classes/MLMessageProcessor.h b/Monal/Classes/MLMessageProcessor.h new file mode 100644 index 0000000..3d126b2 --- /dev/null +++ b/Monal/Classes/MLMessageProcessor.h @@ -0,0 +1,28 @@ +// +// MLMessageProcessor.h +// Monal +// +// Created by Anurodh Pokharel on 9/1/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import +#import "xmpp.h" +#import "XMPPMessage.h" + +NS_ASSUME_NONNULL_BEGIN + +@class MLOMEMO; +@class xmpp; + +@interface MLMessageProcessor : NSObject + +/** + Process a message, persist it and post relevant notifications + */ ++(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessage:(XMPPMessage*) outerMessageNode forAccount:(xmpp*) account; ++(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessage:(XMPPMessage*) outerMessageNode forAccount:(xmpp*) account withHistoryId:(NSNumber* _Nullable) historyId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLMessageProcessor.m b/Monal/Classes/MLMessageProcessor.m new file mode 100644 index 0000000..fd66bb9 --- /dev/null +++ b/Monal/Classes/MLMessageProcessor.m @@ -0,0 +1,884 @@ +// +// MLMessageProcessor.m +// Monal +// +// Created by Anurodh Pokharel on 9/1/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLMessageProcessor.h" +#import "DataLayer.h" +#import "SignalAddress.h" +#import "HelperTools.h" +#import "AESGcm.h" +#import "MLConstants.h" +#import "MLImageManager.h" +#import "XMPPIQ.h" +#import "MLPubSub.h" +#import "MLOMEMO.h" +#import "MLFiletransfer.h" +#import "MLMucProcessor.h" +#import "MLNotificationQueue.h" +#import "MonalAppDelegate.h" + +@interface MLPubSub () +-(void) handleHeadlineMessage:(XMPPMessage*) messageNode; +@end + +static NSMutableDictionary* _typingNotifications; + +@implementation MLMessageProcessor + ++(void) initialize +{ + _typingNotifications = [NSMutableDictionary new]; +} + ++(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessage:(XMPPMessage*) outerMessageNode forAccount:(xmpp*) account +{ + return [self processMessage:messageNode andOuterMessage:outerMessageNode forAccount:account withHistoryId:nil]; +} + ++(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessage:(XMPPMessage*) outerMessageNode forAccount:(xmpp*) account withHistoryId:(NSNumber* _Nullable) historyIdToUse +{ + MLAssert(messageNode != nil, @"messageNode should not be nil!"); + MLAssert(outerMessageNode != nil, @"outerMessageNode should not be nil!"); + MLAssert(account != nil, @"account should not be nil!"); + + //this will be the return value f tis method + //(a valid MLMessage, if this was a new message added to the db or nil, if it was another stanza not added + //directly to the message_history table (but possibly altering it, e.g. marking someentr as read) + MLMessage* message = nil; + + //history messages have already been collected mam-page wise and reordered before they are inserted into db db + //(that's because mam always sorts the messages in a page by timestamp in ascending order) + BOOL isMLhistory = NO; + if([outerMessageNode check:@"{urn:xmpp:mam:2}result"] && [[outerMessageNode findFirst:@"{urn:xmpp:mam:2}result@queryid"] hasPrefix:@"MLhistory:"]) + isMLhistory = YES; + MLAssert(!isMLhistory || historyIdToUse != nil, @"processing of MLhistory: mam messages is only possible if a history id was given", (@{ + @"isMLhistory": @(isMLhistory), + @"historyIdToUse": historyIdToUse != nil ? historyIdToUse : @"(nil)", + })); + + if([messageNode check:@"/"]) + { + DDLogError(@"Error type message received"); + + if(![messageNode check:@"/@id"]) + { + DDLogError(@"Ignoring error messages having an empty ID"); + return nil; + } + + NSString* errorType = [messageNode findFirst:@"error@type"]; + if(!errorType) + errorType= @"unknown error"; + NSString* errorReason = [messageNode findFirst:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}!text$"]; + NSString* errorText = [messageNode findFirst:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}text#"]; + DDLogInfo(@"Got errorType='%@', errorReason='%@', errorText='%@' for message '%@'", errorType, errorReason, errorText, messageNode.id); + + if(errorReason) + errorType = [NSString stringWithFormat:@"%@ - %@", errorType, errorReason]; + if(!errorText) + errorText = NSLocalizedString(@"No further error description", @""); + + //update db + [[DataLayer sharedInstance] + setMessageId:messageNode.id + andJid:messageNode.fromUser + errorType:errorType + errorReason:errorText + ]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMessageErrorNotice object:nil userInfo:@{ + kMessageId: messageNode.id, + @"jid": messageNode.fromUser, + @"errorType": errorType, + @"errorReason": errorText, + }]; + + return nil; + } + + NSString* buddyName = [messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid] ? messageNode.toUser : messageNode.fromUser; + MLContact* possiblyUnknownContact = [MLContact createContactFromJid:buddyName andAccountID:account.accountID]; + + //ignore unknown contacts if configured to do so + if(![[HelperTools defaultsDB] boolForKey: @"allowNonRosterContacts"] && !possiblyUnknownContact.isSubscribedFrom) + { + DDLogWarn(@"Ignoring incoming message stanza from unknown contact: %@", possiblyUnknownContact); + return nil; + } + + //ignore prosody mod_muc_notifications muc push stanzas (they are only needed to trigger an apns push) + //but trigger a muc ping for these mucs nonetheless (if this muc is known, we don't want to arbitrarily join mucs just because of this stanza) + if([messageNode check:@"{http://quobis.com/xmpp/muc#push}notification"]) + { + NSString* roomJid = [messageNode findFirst:@"{http://quobis.com/xmpp/muc#push}notification@jid"]; + if([[[DataLayer sharedInstance] listMucsForAccount:account.accountID] containsObject:roomJid]) + [account.mucProcessor ping:roomJid]; + return nil; + } + + if([messageNode check:@"//{http://jabber.org/protocol/pubsub#event}event"]) + { + [account.pubsub handleHeadlineMessage:messageNode]; + return nil; + } + + //ignore messages from our own device, see this github issue: https://github.com/monal-im/Monal/issues/941 + if(!isMLhistory && [messageNode.from isEqualToString:account.connectionProperties.identity.fullJid] && [messageNode.toUser isEqualToString:account.connectionProperties.identity.jid]) + return nil; + + //handle incoming jmi calls (TODO: add entry to local history, once the UI for this is implemented) + //only handle incoming propose messages if not older than 60 seconds + if([messageNode check:@"{urn:xmpp:jingle-message:0}*"] && ![HelperTools shouldProvideVoip]) + { + DDLogWarn(@"VoIP not supported, ignoring incoming JMI message!"); + return nil; + } + else if([messageNode check:@"{urn:xmpp:jingle-message:0}*"]) + { + MLContact* jmiContact = [MLContact createContactFromJid:messageNode.fromUser andAccountID:account.accountID]; + if([messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid]) + jmiContact = [MLContact createContactFromJid:messageNode.toUser andAccountID:account.accountID]; + + //only handle *incoming* call proposals + if([messageNode check:@"{urn:xmpp:jingle-message:0}propose"]) + { + if(![messageNode.toUser isEqualToString:account.connectionProperties.identity.jid]) + { + //TODO: record this call in history db even if it was outgoing from another device on our account + DDLogWarn(@"Ignoring incoming JMI propose coming from another device on our account"); + return nil; + } + + //only handle jmi stanzas exchanged with contacts allowed to see us and ignore all others + //--> no presence leak and no unwanted spam calls + //but: outgoing calls are still allowed even without presence subscriptions in either direction + if(![[HelperTools defaultsDB] boolForKey:@"allowCallsFromNonRosterContacts"] && !jmiContact.isSubscribedFrom) + { + DDLogWarn(@"Ignoring incoming JMI propose coming from a contact we are not subscribed from"); + return nil; + } + + NSDate* delayStamp = [messageNode findFirst:@"{urn:xmpp:delay}delay@stamp|datetime"]; + if(delayStamp == nil) + delayStamp = [NSDate date]; + if([[NSDate date] timeIntervalSinceDate:delayStamp] > 60.0) + { + DDLogWarn(@"Ignoring incoming JMI propose: too old"); + return nil; + } + + //only allow audio calls for now + if([messageNode check:@"{urn:xmpp:jingle-message:0}propose/{urn:xmpp:jingle:apps:rtp:1}description"]) + { + DDLogInfo(@"Got incoming JMI propose"); + NSDictionary* callData = @{ + @"messageNode": messageNode, + @"accountID": account.accountID, + }; + //this is needed because this file resides in the monalxmpp compilation unit while the MLVoipProcessor resides + //in the monal compilation unit (the ui unit), the NSE resides in yet another compilation unit (the nse-appex unit) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalIncomingVoipCall object:account userInfo:callData]; + } + else + DDLogWarn(@"Ignoring incoming non-audio JMI call, not implemented yet"); + return nil; + } + //handle all other JMI events (TODO: add entry to local history, once the UI for this is implemented) + //if the corresponding call is unknown these will just be ignored by MLVoipProcessor --> no presence leak + else + { + DDLogInfo(@"Got %@ for JMI call %@", [messageNode findFirst:@"{urn:xmpp:jingle-message:0}*$"], [messageNode findFirst:@"{urn:xmpp:jingle-message:0}*@id"]); + if([HelperTools isAppExtension]) + DDLogWarn(@"Ignoring incoming JMI message: we are in the appex which means any outgoing or ongoing call was already terminated"); + else + { + NSDictionary* callData = @{ + @"messageNode": messageNode, + @"accountID": account.accountID, + }; + //this is needed because this file resides in the monalxmpp compilation unit while the MLVoipProcessor resides + //in the monal compilation unit (the ui unit), the NSE resides in yet another compilation unit (the nse-appex unit) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalIncomingJMIStanza object:account userInfo:callData]; + } + return nil; + } + } + + //ignore muc PMs (after discussion with holger we don't want to support that) + if( + ![messageNode check:@"/"] && + [messageNode check:@"{http://jabber.org/protocol/muc#user}x"] && + ![messageNode check:@"{http://jabber.org/protocol/muc#user}x/invite"] && + [messageNode check:@"body#"] + ) + { + DDLogWarn(@"Ignoring muc pm marked as such..."); + //ignore muc pms without id attribute (we can't send out errors pointing to this message without an id) + if(messageNode.id == nil) + return nil; + XMPPMessage* errorReply = [XMPPMessage new]; + [errorReply.attributes setObject:@"error" forKey:@"type"]; + [errorReply.attributes setObject:messageNode.from forKey:@"to"]; //this has to be the full jid here + [errorReply.attributes setObject:messageNode.id forKey:@"id"]; //don't set origin id here + [errorReply addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"cancel"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"feature-not-implemented" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"], + [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas" withAttributes:@{} andChildren:@[] andData:@"The receiver does not seem to support MUC-PMs"] + ] andData:nil]]; + [errorReply setStoreHint]; + [account send:errorReply]; + return nil; + } + //ignore carbon copied muc pms not marked as such + NSString* carbonType = [outerMessageNode findFirst:@"{urn:xmpp:carbons:2}*$"]; + if(carbonType != nil) + { + NSString* maybeMucJid = [carbonType isEqualToString:@"sent"] ? messageNode.toUser : messageNode.fromUser; + MLContact* carbonTestContact = [MLContact createContactFromJid:maybeMucJid andAccountID:account.accountID]; + if(carbonTestContact.isMuc) + { + DDLogWarn(@"Ignoring carbon copied muc pm..."); + return nil; + } + else + DDLogVerbose(@"Not a carbon copy of a muc pm for contact: %@", carbonTestContact); + } + + + if(([messageNode check:@"/"] || [messageNode check:@"{http://jabber.org/protocol/muc#user}x"]) && ![messageNode check:@"{http://jabber.org/protocol/muc#user}x/invite"]) + { + // Ignore all group chat msgs from unkown groups + if(![[[DataLayer sharedInstance] listMucsForAccount:account.accountID] containsObject:messageNode.fromUser]) + { + // ignore message + DDLogWarn(@"Ignoring groupchat message from %@", messageNode.toUser); + return nil; + } + } + else + { + // handle KeyTransportMessages directly without adding a 1:1 buddy + if([messageNode check:@"{eu.siacs.conversations.axolotl}encrypted/header"] == YES && [messageNode check:@"{eu.siacs.conversations.axolotl}encrypted/payload#"] == NO) + { + if(!isMLhistory) + { + DDLogInfo(@"Handling KeyTransportElement without trying to add a 1:1 buddy %@", possiblyUnknownContact); + [account.omemo decryptMessage:messageNode withMucParticipantJid:nil]; + } + else + DDLogInfo(@"Ignoring MLhistory KeyTransportElement for buddy %@", possiblyUnknownContact); + return nil; + } + } + + NSString* stanzaid = [outerMessageNode findFirst:@"{urn:xmpp:mam:2}result@id"]; + //check stanza-id @by according to the rules outlined in XEP-0359 + if(!stanzaid) + { + if(!possiblyUnknownContact.isMuc && [messageNode check:@"{urn:xmpp:sid:0}stanza-id", account.connectionProperties.identity.jid]) + stanzaid = [messageNode findFirst:@"{urn:xmpp:sid:0}stanza-id@id", account.connectionProperties.identity.jid]; + else if(possiblyUnknownContact.isMuc && [messageNode check:@"{urn:xmpp:sid:0}stanza-id", messageNode.fromUser] && [[account.mucProcessor getRoomFeaturesForMuc:messageNode.fromUser] containsObject:@"urn:xmpp:sid:0"]) + stanzaid = [messageNode findFirst:@"{urn:xmpp:sid:0}stanza-id@id", messageNode.fromUser]; + } + + //all modern clients using origin-id should use the same id for origin-id AND message id + NSString* messageId = [messageNode findFirst:@"{urn:xmpp:sid:0}origin-id@id"]; + if(messageId == nil || !messageId.length) + messageId = messageNode.id; + if(messageId == nil || !messageId.length) + { + if([messageNode check:@"body#"]) + DDLogWarn(@"Message containing body has an empty stanza ID, using random UUID instead"); + else + DDLogVerbose(@"Empty stanza ID, using random UUID instead"); + messageId = [[NSUUID UUID] UUIDString]; + } + + //handle muc status changes or invites (this checks for the muc namespace itself) + if(isMLhistory) + { + if([messageNode check:@"{http://jabber.org/protocol/muc#user}x/invite"] || ([messageNode check:@"{jabber:x:conference}x@jid"] && [[messageNode findFirst:@"{jabber:x:conference}x@jid"] length] > 0)) + return nil; //stop processing because this is a (mediated) muc invite received through backscrolling history + else + ; //continue processing for backscrolling history but don't call mucProcessor.processMessage to not process ancient status/memberlist updates + } + else if([account.mucProcessor processMessage:messageNode]) + { + DDLogVerbose(@"Muc processor said we have to stop message processing here..."); + return nil; //the muc processor said we have stop processing + } + + //add contact if possible (ignore groupchats or already existing contacts, or KeyTransportElements) + DDLogInfo(@"Adding possibly unknown contact for %@ to local contactlist (not updating remote roster!), doing nothing if contact is already known...", possiblyUnknownContact); + [[DataLayer sharedInstance] addContact:possiblyUnknownContact.contactJid forAccount:account.accountID nickname:nil]; + + NSString* ownNick = nil; + NSString* ownOccupantId = nil; + NSString* actualFrom = messageNode.fromUser; + NSString* participantJid = nil; + NSString* occupantId = nil; + if(possiblyUnknownContact.isMuc) + { + actualFrom = messageNode.fromResource ?: @""; + + ownNick = [[DataLayer sharedInstance] ownNickNameforMuc:messageNode.fromUser forAccount:account.accountID]; + ownOccupantId = [[DataLayer sharedInstance] getOwnOccupantIdForMuc:messageNode.fromUser onAccountID:account.accountID]; + + //occupant ids are widely supported now and allow us to have a stable identifier of every muc participant, + //even if it is a semi-anonymous channel + if([[account.mucProcessor getRoomFeaturesForMuc:messageNode.fromUser] containsObject:@"urn:xmpp:occupant-id:0"] && [messageNode check:@"{urn:xmpp:occupant-id:0}occupant-id@id"]) + { + occupantId = [messageNode findFirst:@"{urn:xmpp:occupant-id:0}occupant-id@id"]; + NSDictionary* mucParticipant = [[DataLayer sharedInstance] getParticipantForOccupant:occupantId inRoom:messageNode.fromUser forAccountID:account.accountID]; + //we will be able to get to know the real jid, if this is a group or we are the channel admin + participantJid = mucParticipant ? mucParticipant[@"participant_jid"] : nil; + } + + //mam catchups will contain a muc#user item listing the jid of the participant + //this can't be reconstructed from *current* participant lists because someone new could have taken the same nick + //we don't accept this in non-mam context to make sure this can't be spoofed somehow + //we also don't do that, if this was a message from the bare muc jid + //NOTE: this will override the participantJid extracted using the occupantId above, + //NOTE: but those should ALWAYS be the same (that's the exact purpose of occupant ids) + if([outerMessageNode check:@"{urn:xmpp:mam:2}result"] && ![@"" isEqualToString:actualFrom]) + participantJid = [messageNode findFirst:@"{http://jabber.org/protocol/muc#user}x/item@jid"]; + + //try to get the jid of the current participant if the occupant-id based approach above did not work + //but don't do so, if this was a message from the bare muc jid + if(![outerMessageNode check:@"{urn:xmpp:mam:2}result"] && occupantId == nil && participantJid == nil && ![@"" isEqualToString:actualFrom]) + { + NSDictionary* mucParticipant = [[DataLayer sharedInstance] getParticipantForNick:actualFrom inRoom:messageNode.fromUser forAccountID:account.accountID]; + participantJid = mucParticipant ? mucParticipant[@"participant_jid"] : nil; + } + + //make sure this is not the full jid + if(participantJid != nil) + participantJid = [HelperTools splitJid:participantJid][@"user"]; + DDLogInfo(@"Extracted participantJid: %@", participantJid); + } + + //inbound value for 1:1 chats + BOOL inbound = ![messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid]; + //inbound value for groupchat messages + if(ownNick != nil) + { + //we got an occupant-id? --> use that for inbound calculation + //use the reported jid otherwise (note: biboumi will report made-up jids not matching our real jid, so this will fail) + //if both don't work, try the nickname (but only for inbound calculation, NOT for calculating LMC or retraction auth) + if(occupantId != nil) + inbound = ![occupantId isEqualToString:ownOccupantId]; + else if(participantJid != nil) + inbound = ![participantJid isEqualToString:account.connectionProperties.identity.jid]; + else + inbound = ![ownNick isEqualToString:actualFrom]; + DDLogDebug(@"This is muc, inbound is now: %@ (ownNick: %@, ownOccupantId: %@, ownJid: %@, occupantId: %@, actualFrom: %@, participantJid: %@)", bool2str(inbound), ownNick, ownOccupantId, account.connectionProperties.identity.jid, occupantId, actualFrom, participantJid); + } + + if([messageNode check:@"//subject"]) + { + if(!isMLhistory) + { + NSString* subject = nilDefault([messageNode findFirst:@"//subject#"], @""); + subject = [subject stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + NSString* currentSubject = [[DataLayer sharedInstance] mucSubjectforAccount:account.accountID andRoom:messageNode.fromUser]; + DDLogInfo(@"Got MUC subject for %@: '%@'", messageNode.fromUser, subject); + + if([subject isEqualToString:currentSubject]) + { + DDLogVerbose(@"Ignoring subject, nothing changed..."); + return nil; + } + + DDLogVerbose(@"Updating subject in database: %@", subject); + [[DataLayer sharedInstance] updateMucSubject:subject forAccount:account.accountID andRoom:messageNode.fromUser]; + + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMucSubjectChanged object:account userInfo:@{ + @"room": messageNode.fromUser, + @"subject": subject, + }]; + } + else + DDLogVerbose(@"Ignoring muc subject: isMLhistory=YES..."); + return nil; + } + + NSString* decrypted; + if([messageNode check:@"{eu.siacs.conversations.axolotl}encrypted/header"]) + { + if(isMLhistory) + { + //only show error for real messages having a fallback body, not for silent key exchange messages + if([messageNode check:@"body#"]) + { +//use the fallback body on alpha builds (changes are good this fallback body really is the cleartext of the message because of "opportunistic" encryption) +#ifndef IS_ALPHA + decrypted = NSLocalizedString(@"Message was encrypted with OMEMO and can't be decrypted anymore", @""); +#endif + } + else + DDLogInfo(@"Ignoring encrypted mam history message without fallback body"); + } + else + decrypted = [account.omemo decryptMessage:messageNode withMucParticipantJid:participantJid]; + + DDLogVerbose(@"Decrypted: %@", decrypted); + } + +#ifdef IS_ALPHA + //thats the negation of our case from line 375 + //--> opportunistic omemo in alpha builds should use the fallback body instead of the EME error because the fallback body could be the cleartext message + // (it could be a real omemo fallback, too, but there is no harm in using that instead of the EME message) + if(!([messageNode check:@"{eu.siacs.conversations.axolotl}encrypted/header"] && isMLhistory && [messageNode check:@"body#"])) +#endif + //implement reading support for EME for messages having a fallback body (e.g. no silent key exchanges) that could not be decrypted + //this sets the var "decrypted" to the locally generated "fallback body" + if([messageNode check:@"body#"] && !decrypted && [messageNode check:@"{urn:xmpp:eme:0}encryption@namespace"]) + { + if([[messageNode findFirst:@"{urn:xmpp:eme:0}encryption@namespace"] isEqualToString:@"eu.siacs.conversations.axolotl"]) + decrypted = NSLocalizedString(@"Message was encrypted with OMEMO but could not be decrypted", @""); + else + { + NSString* encryptionName = [messageNode check:@"{urn:xmpp:eme:0}encryption@name"] ? [messageNode findFirst:@"{urn:xmpp:eme:0}encryption@name"] : [messageNode findFirst:@"{urn:xmpp:eme:0}encryption@namespace"]; + //hardcoded names mandated by XEP 0380 + if([[messageNode findFirst:@"{urn:xmpp:eme:0}encryption@namespace"] isEqualToString:@"urn:xmpp:otr:0"]) + encryptionName = @"OTR"; + else if([[messageNode findFirst:@"{urn:xmpp:eme:0}encryption@namespace"] isEqualToString:@"jabber:x:encrypted"]) + encryptionName = @"Legacy OpenPGP"; + else if([[messageNode findFirst:@"{urn:xmpp:eme:0}encryption@namespace"] isEqualToString:@"urn:xmpp:openpgp:0"]) + encryptionName = @"OpenPGP for XMPP"; + decrypted = [NSString stringWithFormat:NSLocalizedString(@"Message was encrypted with '%@' which isn't supported by Monal", @""), encryptionName]; + } + } + + //ignore encrypted messages coming from our own device id (most probably a muc reflection) + BOOL sentByOwnOmemoDevice = NO; +#ifndef DISABLE_OMEMO + if([messageNode check:@"{eu.siacs.conversations.axolotl}encrypted/header@sid|uint"]) + sentByOwnOmemoDevice = ((NSNumber*)[messageNode findFirst:@"{eu.siacs.conversations.axolotl}encrypted/header@sid|uint"]).unsignedIntValue == [account.omemo getDeviceId].unsignedIntValue; +#endif + + //handle message retraction (XEP-0424) + if([messageNode check:@"{urn:xmpp:message-retract:1}retract"]) + { + NSString* idToRetract = [messageNode findFirst:@"{urn:xmpp:message-retract:1}retract@id"]; + NSNumber* historyIdToRetract = nil; + if(possiblyUnknownContact.isMuc && [[account.mucProcessor getRoomFeaturesForMuc:possiblyUnknownContact.contactJid] containsObject:@"urn:xmpp:message-moderate:1"] && [messageNode findFirst:@"{urn:xmpp:message-retract:1}retract/{urn:xmpp:message-moderate:1}moderated"]) + { + historyIdToRetract = [[DataLayer sharedInstance] getRetractionHistoryIDForModeratedStanzaId:idToRetract from:messageNode.fromUser andAccount:account.accountID]; + } + else + { + //this checks for everything spelled out in the business rules of XEP-0424 + historyIdToRetract = [[DataLayer sharedInstance] getRetractionHistoryIDForMessageId:idToRetract from:messageNode.fromUser participantJid:participantJid occupantId:occupantId andAccount:account.accountID]; + } + + if(historyIdToRetract != nil) + { + [[DataLayer sharedInstance] retractMessageHistory:historyIdToRetract]; + + //update ui + DDLogInfo(@"Sending out kMonalDeletedMessageNotice notification for historyId %@", historyIdToRetract); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDeletedMessageNotice object:account userInfo:@{ + @"message": [[[DataLayer sharedInstance] messagesForHistoryIDs:@[historyIdToRetract]] firstObject], + @"contact": possiblyUnknownContact, + }]; + + //update unread count in active chats list + [possiblyUnknownContact updateUnreadCount]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": possiblyUnknownContact, + }]; + } + else + DDLogWarn(@"Could not find history ID for idToRetract '%@' from '%@' on account %@", idToRetract, messageNode.fromUser, account.accountID); + } + //handle retraction tombstone in MAM (XEP-0424) + else if([outerMessageNode check:@"{urn:xmpp:mam:2}result"] && [messageNode check:@"{urn:xmpp:message-retract:1}retracted@id"]) + { + //ignore tombstones if not supported by server (someone probably faked them) + if( + (!possiblyUnknownContact.isMuc && [account.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:message-retract:1#tombstone"]) || + (possiblyUnknownContact.isMuc && [[account.mucProcessor getRoomFeaturesForMuc:possiblyUnknownContact.contactJid] containsObject:@"urn:xmpp:message-retract:1#tombstone"]) + ) + { + //first add an empty message into our history db... + NSNumber* historyIdToRetract = [[DataLayer sharedInstance] + addMessageToChatBuddy:buddyName + withInboundDir:inbound + forAccount:account.accountID + withBody:@"" + actuallyfrom:actualFrom + occupantId:occupantId + participantJid:participantJid + sent:YES + unread:NO + messageId:messageId + serverMessageId:stanzaid + messageType:kMessageTypeText + andOverrideDate:[messageNode findFirst:@"{urn:xmpp:delay}delay@stamp|datetime"] + encrypted:NO + displayMarkerWanted:NO + usingHistoryId:historyIdToUse + checkForDuplicates:[messageNode check:@"{urn:xmpp:sid:0}origin-id"] || (stanzaid != nil) + ]; + + //...then retract this message (e.g. mark as retracted) + [[DataLayer sharedInstance] retractMessageHistory:historyIdToRetract]; + + //update ui + DDLogInfo(@"Sending out kMonalDeletedMessageNotice notification for historyId %@", historyIdToRetract); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDeletedMessageNotice object:account userInfo:@{ + @"message": [[[DataLayer sharedInstance] messagesForHistoryIDs:@[historyIdToRetract]] firstObject], + @"historyId": historyIdToRetract, + @"contact": possiblyUnknownContact, + }]; + } + else + DDLogWarn(@"Got faked tombstone without server supporting them, ignoring it!"); + } + //ignore encrypted body messages coming from our own device id (most probably a muc reflection) + else if(([messageNode check:@"body#"] || decrypted) && !sentByOwnOmemoDevice) + { + BOOL unread = YES; + BOOL showAlert = YES; + + //if incoming or mam catchup we DO want an alert, otherwise we don't + //this will set unread=NO for MLhistory mssages, too (which is desired) + if( + !inbound || + ([outerMessageNode check:@"{urn:xmpp:mam:2}result"] && ![[outerMessageNode findFirst:@"{urn:xmpp:mam:2}result@queryid"] hasPrefix:@"MLcatchup:"]) + ) + { + DDLogVerbose(@"Setting showAlert to NO"); + showAlert = NO; + unread = NO; + } + + NSString* messageType = kMessageTypeText; + BOOL encrypted = NO; + NSString* body = [messageNode findFirst:@"body#"]; + if(decrypted) + { + body = decrypted; + encrypted = YES; + } + body = [body stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + + //messages with oob tag are filetransfers (but only if they are https urls) + NSString* lowercaseBody = [body lowercaseString]; + if(body && [body isEqualToString:[messageNode findFirst:@"{jabber:x:oob}x/url#"]] && [lowercaseBody hasPrefix:@"https://"]) + messageType = kMessageTypeFiletransfer; + //messages without spaces are potentially special ones + else if([body rangeOfString:@" "].location == NSNotFound) + { + if([lowercaseBody hasPrefix:@"geo:"]) + messageType = kMessageTypeGeo; + //encrypted messages having one single string prefixed with "aesgcm:" are filetransfers, too (xep-0454) + else if(encrypted && [lowercaseBody hasPrefix:@"aesgcm://"]) + messageType = kMessageTypeFiletransfer; + else if([lowercaseBody hasPrefix:@"https://"]) + messageType = kMessageTypeUrl; + } + //messages from the bare muc jid are classified as status messages + if(possiblyUnknownContact.isMuc && [@"" isEqualToString:actualFrom]) + messageType = kMessageTypeStatus; + DDLogInfo(@"Got message of type: %@", messageType); + + if(body) + { + BOOL LMCReplaced = NO; + NSNumber* historyId = nil; + + //handle LMC + if([messageNode check:@"{urn:xmpp:message-correct:0}replace"]) + { + NSString* messageIdToReplace = [messageNode findFirst:@"{urn:xmpp:message-correct:0}replace@id"]; + DDLogVerbose(@"Message id to LMC-replace: %@", messageIdToReplace); + //this checks if this message is from the same jid as the message it tries to do the LMC for (e.g. inbound can only correct inbound and outbound only outbound) + historyId = [[DataLayer sharedInstance] getLMCHistoryIDForMessageId:messageIdToReplace from:messageNode.fromUser occupantId:occupantId participantJid:participantJid andAccount:account.accountID]; + DDLogVerbose(@"History id to LMC-replace: %@", historyId); + //now check if the LMC is allowed (we use historyIdToUse for MLhistory mam queries to only check LMC for the 3 messages coming before this ID in this converastion) + //historyIdToUse will be nil, for messages going forward in time which means (check for the newest 3 messages in this conversation) + if(historyId != nil && [[DataLayer sharedInstance] checkLMCEligible:historyId encrypted:encrypted historyBaseID:historyIdToUse]) + { + [[DataLayer sharedInstance] updateMessageHistory:historyId withText:body]; + LMCReplaced = YES; + } + else + historyId = nil; + } + + //handle normal messages or LMC messages that can not be found + //(this will update stanzaid in database, too, if deduplication detects a duplicate/reflection) + if(historyId == nil) + { + historyId = [[DataLayer sharedInstance] + addMessageToChatBuddy:buddyName + withInboundDir:inbound + forAccount:account.accountID + withBody:[body copy] + actuallyfrom:actualFrom + occupantId:occupantId + participantJid:participantJid + sent:YES + unread:unread + messageId:messageId + serverMessageId:stanzaid + messageType:messageType + andOverrideDate:[messageNode findFirst:@"{urn:xmpp:delay}delay@stamp|datetime"] + encrypted:encrypted + displayMarkerWanted:[messageNode check:@"{urn:xmpp:chat-markers:0}markable"] + usingHistoryId:historyIdToUse + checkForDuplicates:[messageNode check:@"{urn:xmpp:sid:0}origin-id"] || (stanzaid != nil) + ]; + } + + message = [[DataLayer sharedInstance] messageForHistoryID:historyId]; + if(message != nil && historyId != nil) //check historyId to make static analyzer happy + { + //send receive markers if requested, but DON'T do so for MLhistory messages (and don't do so for channel type mucs) + if( + [[HelperTools defaultsDB] boolForKey:@"SendReceivedMarkers"] && + [messageNode check:@"{urn:xmpp:receipts}request"] && + ![messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid] && + !isMLhistory + ) + { + //ignore unknown groupchats or channel-type mucs or stanzas from the groupchat itself (e.g. not from a participant having a full jid) + if( + //1:1 with user in our contact list that subscribed us (e.g. is allowed to see us) + (!possiblyUnknownContact.isMuc && possiblyUnknownContact.isSubscribedFrom) || + //muc group message from a user of this group + ([possiblyUnknownContact.mucType isEqualToString:kMucTypeGroup] && messageNode.fromResource) + ) + { + XMPPMessage* receiptNode = [XMPPMessage new]; + //the message type is needed so that the store hint is accepted by the server --> mirror the incoming type + receiptNode.attributes[@"type"] = [messageNode findFirst:@"/@type"]; + receiptNode.attributes[@"to"] = messageNode.fromUser; + if([messageNode check:@"{urn:xmpp:receipts}request"]) + [receiptNode setReceipt:messageId]; + [receiptNode setStoreHint]; + [account send:receiptNode]; + } + } + + //check if we have an outgoing message sent from another client on our account + //if true we can mark all messages from this buddy as already read by us (using the other client) + //this only holds rue for non-MLhistory messages of course + //WARNING: kMonalMessageDisplayedNotice goes to chatViewController, kMonalDisplayedMessagesNotice goes to MLNotificationManager and activeChatsViewController/chatViewController + //e.g.: kMonalMessageDisplayedNotice means "remote party read our message" and kMonalDisplayedMessagesNotice means "we read a message" + if(body && stanzaid && !inbound && !isMLhistory) + { + DDLogInfo(@"Got outgoing message to contact '%@' sent by another client, removing all notifications for unread messages of this contact", buddyName); + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:buddyName andAccount:account.accountID tillStanzaId:stanzaid wasOutgoing:NO]; + DDLogDebug(@"Marked as read: %@", unread); + + //remove notifications of all remotely read messages (indicated by sending a response message) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:account userInfo:@{@"messagesArray":unread}]; + + //update unread count in active chats list + if([unread count]) + { + [possiblyUnknownContact updateUnreadCount]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": possiblyUnknownContact, + }]; + } + } + + [[DataLayer sharedInstance] addActiveBuddies:buddyName forAccount:account.accountID]; + + DDLogInfo(@"Sending out kMonalNewMessageNotice notification for historyId %@", historyId); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalNewMessageNotice object:account userInfo:@{ + @"message": message, + @"showAlert": @(showAlert), + @"contact": possiblyUnknownContact, + @"LMCReplaced": @(LMCReplaced), + }]; + + //try to automatically determine content type of filetransfers + if(messageType == kMessageTypeFiletransfer && [[HelperTools defaultsDB] boolForKey:@"AutodownloadFiletransfers"]) + [MLFiletransfer checkMimeTypeAndSizeForHistoryID:historyId]; + } + } + } + else if(!inbound) + { + //just try to use the probably reflected message to update the stanzaid of our message in the db + //messageId is always a proper origin-id in this case, because inbound == NO and Monal uses origin-ids + NSNumber* historyId = [[DataLayer sharedInstance] hasMessageForStanzaId:stanzaid orMessageID:messageId withInboundDir:inbound occupantId:occupantId andJid:buddyName onAccount:account.accountID]; + if(historyId != nil) + { + message = [[DataLayer sharedInstance] messageForHistoryID:historyId]; + DDLogDebug(@"Managed to update stanzaid of message (or stanzaid already known): %@", message); + DDLogInfo(@"Sending out kMonalNewMessageNotice notification for historyId %@", historyId); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalNewMessageNotice object:account userInfo:@{ + @"message": message, + @"showAlert": @(NO), + @"contact": possiblyUnknownContact, + }]; + } + } + + //handle message receipts + if( + [messageNode check:@"{urn:xmpp:receipts}received@id"] && + [messageNode.toUser isEqualToString:account.connectionProperties.identity.jid] + ) + { + NSString* msgId = [messageNode findFirst:@"{urn:xmpp:receipts}received@id"]; + + //save in DB + [[DataLayer sharedInstance] setMessageId:msgId andJid:messageNode.fromUser received:YES]; + + //Post notice + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMessageReceivedNotice object:self userInfo:@{ + kMessageId:msgId, + @"jid": messageNode.fromUser, + }]; + } + + //handle chat-markers in groupchats slightly different + if([messageNode check:@"{urn:xmpp:chat-markers:0}displayed@id"] && ownNick != nil) + { + //ignore unknown groupchats or channel-type mucs or stanzas from the groupchat itself (e.g. not from a participant having a full jid) + if(possiblyUnknownContact.isMuc && [possiblyUnknownContact.mucType isEqualToString:kMucTypeGroup] && messageNode.fromResource) + { + //incoming chat markers from own account (muc echo, muc "carbon") + //WARNING: kMonalMessageDisplayedNotice goes to chatViewController, kMonalDisplayedMessagesNotice goes to MLNotificationManager and activeChatsViewController/chatViewController + //e.g.: kMonalMessageDisplayedNotice means "remote party read our message" and kMonalDisplayedMessagesNotice means "we read a message" + if(!inbound) + { + DDLogInfo(@"Got OWN muc display marker in %@ for stanzaid: %@", buddyName, [messageNode findFirst:@"{urn:xmpp:chat-markers:0}displayed@id"]); + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:buddyName andAccount:account.accountID tillStanzaId:[messageNode findFirst:@"{urn:xmpp:chat-markers:0}displayed@id"] wasOutgoing:NO]; + DDLogDebug(@"Marked as read: %@", unread); + + //remove notifications of all remotely read messages (indicated by sending a display marker) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:account userInfo:@{@"messagesArray":unread}]; + + //update unread count in active chats list + if([unread count]) + { + [possiblyUnknownContact updateUnreadCount]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": possiblyUnknownContact, + }]; + } + } + //incoming chat markers from participant + //this will mark groupchat messages as read as soon as one of the participants sends a displayed chat-marker + //WARNING: kMonalMessageDisplayedNotice goes to chatViewController, kMonalDisplayedMessagesNotice goes to MLNotificationManager and activeChatsViewController/chatViewController + //e.g.: kMonalMessageDisplayedNotice means "remote party read our message" and kMonalDisplayedMessagesNotice means "we read a message" + else + { + DDLogInfo(@"Got remote muc display marker from %@ for stanzaid: %@", messageNode.from, [messageNode findFirst:@"{urn:xmpp:chat-markers:0}displayed@id"]); + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:buddyName andAccount:account.accountID tillStanzaId:[messageNode findFirst:@"{urn:xmpp:chat-markers:0}displayed@id"] wasOutgoing:YES]; + DDLogDebug(@"Marked as displayed: %@", unread); + for(MLMessage* msg in unread) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMessageDisplayedNotice object:account userInfo:@{@"message":msg, kMessageId:msg.messageId}]; + } + } + } + else if([messageNode check:@"{urn:xmpp:chat-markers:0}displayed@id"]) + { + //incoming chat markers from contact + //WARNING: kMonalMessageDisplayedNotice goes to chatViewController, kMonalDisplayedMessagesNotice goes to MLNotificationManager and activeChatsViewController/chatViewController + //e.g.: kMonalMessageDisplayedNotice means "remote party read our message" and kMonalDisplayedMessagesNotice means "we read a message" + if(inbound) + { + DDLogInfo(@"Got remote display marker from %@ for message id: %@", messageNode.fromUser, [messageNode findFirst:@"{urn:xmpp:chat-markers:0}displayed@id"]); + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:messageNode.fromUser andAccount:account.accountID tillStanzaId:[messageNode findFirst:@"{urn:xmpp:chat-markers:0}displayed@id"] wasOutgoing:YES]; + DDLogDebug(@"Marked as displayed: %@", unread); + for(MLMessage* msg in unread) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMessageDisplayedNotice object:account userInfo:@{@"message":msg, kMessageId:msg.messageId}]; + } + //incoming chat markers from own account (carbon copy) + //WARNING: kMonalMessageDisplayedNotice goes to chatViewController, kMonalDisplayedMessagesNotice goes to MLNotificationManager and activeChatsViewController/chatViewController + //e.g.: kMonalMessageDisplayedNotice means "remote party read our message" and kMonalDisplayedMessagesNotice means "we read a message" + else + { + DDLogInfo(@"Got OWN display marker to %@ for message id: %@", messageNode.toUser, [messageNode findFirst:@"{urn:xmpp:chat-markers:0}displayed@id"]); + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:messageNode.toUser andAccount:account.accountID tillStanzaId:[messageNode findFirst:@"{urn:xmpp:chat-markers:0}displayed@id"] wasOutgoing:NO]; + DDLogDebug(@"Marked as read: %@", unread); + + //remove notifications of all remotely read messages (indicated by sending a display marker) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:account userInfo:@{@"messagesArray":unread}]; + + //update unread count in active chats list + if([unread count]) + { + [possiblyUnknownContact updateUnreadCount]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": possiblyUnknownContact, + }]; + } + } + } + + //handle typing notifications but ignore them in appex or for mam fetches (*any* mam fetches are ignored here, chatstates should *never* be in a mam archive!) + if(![HelperTools isAppExtension] && ![outerMessageNode check:@"{urn:xmpp:mam:2}result"]) + { + //only use "is typing" messages when not older than 2 minutes (always allow "not typing" messages) + if( + [messageNode check:@"{http://jabber.org/protocol/chatstates}*"] && + [[DataLayer sharedInstance] checkCap:@"http://jabber.org/protocol/chatstates" forUser:messageNode.fromUser onAccountID:account.accountID] + ) + { + //deduce state + BOOL composing = NO; + if([@"active" isEqualToString:[messageNode findFirst:@"{http://jabber.org/protocol/chatstates}*$"]]) + composing = NO; + else if([@"composing" isEqualToString:[messageNode findFirst:@"{http://jabber.org/protocol/chatstates}*$"]]) + composing = YES; + else if([@"paused" isEqualToString:[messageNode findFirst:@"{http://jabber.org/protocol/chatstates}*$"]]) + composing = NO; + else if([@"inactive" isEqualToString:[messageNode findFirst:@"{http://jabber.org/protocol/chatstates}*$"]]) + composing = NO; + + //handle state + if( + ( + composing && + ( + ![messageNode check:@"{urn:xmpp:delay}delay@stamp"] || + [[NSDate date] timeIntervalSinceDate:[messageNode findFirst:@"{urn:xmpp:delay}delay@stamp|datetime"]] < 120 + ) + ) || + !composing + ) + { + [[MLNotificationQueue currentQueue] postNotificationName:kMonalLastInteractionUpdatedNotice object:self userInfo:@{ + @"jid": messageNode.fromUser, + @"accountID": account.accountID, + @"isTyping": composing ? @YES : @NO + }]; + //send "not typing" notifications (kMonalLastInteractionUpdatedNotice) 60 seconds after the last isTyping was received + @synchronized(_typingNotifications) { + //copy needed values into local variables to not retain self by our timer block + NSString* jid = messageNode.fromUser; + //abort old timer on new isTyping or isNotTyping message + if(_typingNotifications[messageNode.fromUser]) + ((monal_void_block_t) _typingNotifications[messageNode.fromUser])(); + //start a new timer for every isTyping message + if(composing) + { + _typingNotifications[messageNode.fromUser] = createTimer(60, (^{ + [[MLNotificationQueue currentQueue] postNotificationName:kMonalLastInteractionUpdatedNotice object:[[NSDate date] initWithTimeIntervalSince1970:0] userInfo:@{ + @"jid": jid, + @"accountID": account.accountID, + @"isTyping": @NO + }]; + })); + } + } + } + } + } + + return message; +} + +@end diff --git a/Monal/Classes/MLMucProcessor.h b/Monal/Classes/MLMucProcessor.h new file mode 100644 index 0000000..d8fd57c --- /dev/null +++ b/Monal/Classes/MLMucProcessor.h @@ -0,0 +1,45 @@ +// +// MLMucProcessor.h +// monalxmpp +// +// Created by Thilo Molitor on 29.12.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@class XMPPPresence; +@class XMPPMessage; +@class xmpp; + +@interface MLMucProcessor : NSObject + +-(void) addUIHandler:(monal_id_block_t) handler forMuc:(NSString*) room; +-(void) removeUIHandlerForMuc:(NSString*) room; + +-(void) processPresence:(XMPPPresence*) presenceNode; +-(BOOL) processMessage:(XMPPMessage*) messageNode; + +-(void) join:(NSString*) room; +-(void) leave:(NSString*) room withBookmarksUpdate:(BOOL) updateBookmarks keepBuddylistEntry:(BOOL) keepBuddylistEntry; + +//muc management methods +-(NSString* _Nullable) generateMucJid; +-(NSString* _Nullable) createGroup:(NSString*) room; +-(void) destroyRoom:(NSString*) room; +-(void) changeNameOfMuc:(NSString*) room to:(NSString*) name; +-(void) changeSubjectOfMuc:(NSString*) room to:(NSString*) subject; +-(void) publishAvatar:(UIImage* _Nullable) image forMuc:(NSString*) room; +-(void) setAffiliation:(NSString*) affiliation ofUser:(NSString*) jid inMuc:(NSString*) roomJid; +-(void) inviteUser:(NSString*) jid inMuc:(NSString*) roomJid; + +-(void) pingAllMucs; +-(void) ping:(NSString*) roomJid; +-(BOOL) checkIfStillBookmarked:(NSString*) room; +-(NSSet*) getRoomFeaturesForMuc:(NSString*) room; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLMucProcessor.m b/Monal/Classes/MLMucProcessor.m new file mode 100644 index 0000000..57acaa3 --- /dev/null +++ b/Monal/Classes/MLMucProcessor.m @@ -0,0 +1,1846 @@ +// +// MLMucProcessor.m +// monalxmpp +// +// Created by Thilo Molitor on 29.12.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import + +#import "MLConstants.h" +#import "MLMucProcessor.h" +#import "MLHandler.h" +#import "xmpp.h" +#import "DataLayer.h" +#import "XMPPDataForm.h" +#import "XMPPIQ.h" +#import "XMPPMessage.h" +#import "XMPPPresence.h" +#import "MLNotificationQueue.h" +#import "MLPubSub.h" +#import "MLPubSubProcessor.h" +#import "MLOMEMO.h" +#import "MLImageManager.h" + +#define CURRENT_MUC_STATE_VERSION @9 + +@interface MLMucProcessor() +{ + __weak xmpp* _account; + //persistent state + NSObject* _stateLockObject; + NSMutableDictionary* _roomFeatures; + NSMutableDictionary* _creating; + NSMutableDictionary* _joining; + NSMutableSet* _destroying; + NSMutableSet* _firstJoin; + NSMutableDictionary* _changingName; + NSDate* _lastPing; + NSMutableSet* _noUpdateBookmarks; + BOOL _hasFetchedBookmarks; + //these won't be persisted because it is only for the ui + NSMutableDictionary* _uiHandler; +} +@end + +@implementation MLMucProcessor + +static NSDictionary* _mandatoryGroupConfigOptions; +static NSDictionary* _optionalGroupConfigOptions; + ++(void) initialize +{ + _mandatoryGroupConfigOptions = @{ + @"muc#roomconfig_persistentroom": @"1", + @"muc#roomconfig_membersonly": @"1", + @"muc#roomconfig_whois": @"anyone", + //TODO: mark mam as mandatory + }; + _optionalGroupConfigOptions = @{ + @"muc#roomconfig_enablelogging": @"0", + @"muc#roomconfig_changesubject": @"0", + @"muc#roomconfig_allowinvites": @"0", + @"muc#roomconfig_getmemberlist": kMucRoleParticipant, + @"muc#roomconfig_publicroom": @"0", + @"muc#roomconfig_moderatedroom": @"0", + @"muc#maxhistoryfetch": @"0", //should use mam + }; + +} + +-(id) initWithAccount:(xmpp*) account +{ + self = [super init]; + _account = account; + _stateLockObject = [NSObject new]; + _roomFeatures = [NSMutableDictionary new]; + _creating = [NSMutableDictionary new]; + _joining = [NSMutableDictionary new]; + _destroying = [NSMutableSet new]; + _firstJoin = [NSMutableSet new]; + _changingName = [NSMutableDictionary new]; + _uiHandler = [NSMutableDictionary new]; + _lastPing = [NSDate date]; + _noUpdateBookmarks = [NSMutableSet new]; + _hasFetchedBookmarks = NO; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleResourceBound:) name:kMLResourceBoundNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleCatchupDone:) name:kMonalFinishedCatchup object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleSentMessage:) name:kMonalSentMessageNotice object:nil]; + return self; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(void) setInternalState:(NSDictionary*) state +{ + //DDLogVerbose(@"Setting MUC state to: %@", state); + + //ignore state having wrong version code + if(!state[@"version"] || ![state[@"version"] isEqual:CURRENT_MUC_STATE_VERSION]) + { + DDLogDebug(@"Ignoring MUC state having wrong version: %@ != %@", state[@"version"], CURRENT_MUC_STATE_VERSION); + return; + } + + //extract state + @synchronized(_stateLockObject) { + _roomFeatures = [state[@"roomFeatures"] mutableCopy]; + _creating = [state[@"creating"] mutableCopy]; + _joining = [state[@"joining"] mutableCopy]; + _destroying = [state[@"destroying"] mutableCopy]; + _firstJoin = [state[@"firstJoin"] mutableCopy]; + _changingName = [state[@"changingName"] mutableCopy]; + _lastPing = state[@"lastPing"]; + _noUpdateBookmarks = [state[@"noUpdateBookmarks"] mutableCopy]; + _hasFetchedBookmarks = [state[@"hasFetchedBookmarks"] boolValue]; + } +} + +-(NSDictionary*) getInternalState +{ + @synchronized(_stateLockObject) { + NSDictionary* state = @{ + @"version": CURRENT_MUC_STATE_VERSION, + @"roomFeatures": [_roomFeatures copy], + @"creating": [_creating copy], + @"joining": [_joining copy], + @"destroying": [_destroying copy], + @"firstJoin": [_firstJoin copy], + @"changingName": [_changingName copy], + @"lastPing": _lastPing, + @"noUpdateBookmarks": [_noUpdateBookmarks copy], + @"hasFetchedBookmarks": @(_hasFetchedBookmarks), + }; + //DDLogVerbose(@"Returning MUC state: %@", state); + return state; + } +} + +-(void) handleResourceBound:(NSNotification*) notification +{ + //this event will be called as soon as we are bound, but BEFORE mam catchup happens + //NOTE: this event won't be called for smacks resumes! + if(_account == ((xmpp*)notification.object)) + { + @synchronized(_stateLockObject) { + _roomFeatures = [NSMutableDictionary new]; + _destroying = [NSMutableSet new]; + _changingName = [NSMutableDictionary new]; + + //make sure all idle timers get invalidated properly + NSDictionary* joiningCopy = [_joining copy]; + for(NSString* room in joiningCopy) + [self removeRoomFromJoining:room]; + NSDictionary* creatingCopy = [_creating copy]; + for(NSString* room in creatingCopy) + [self removeRoomFromCreating:room]; + + //don't clear _firstJoin and _noUpdateBookmarks to make sure half-joined mucs are still added to muc bookmarks + + //load all bookmarks 2 items as soon as our catchup is done (+notify only provides one/the last item) + _hasFetchedBookmarks = NO; + } + + //join MUCs from (current) muc_favorites db, the pending bookmarks fetch will join the remaining currently unknown mucs + for(NSString* room in [[DataLayer sharedInstance] listMucsForAccount:_account.accountID]) + [self join:room]; + } +} + +-(void) handleCatchupDone:(NSNotification*) notification +{ + //this event will be called as soon as mam OR smacks catchup on our account is done, it does not wait for muc mam catchups! + if(_account == ((xmpp*)notification.object)) + { + //fake incoming bookmarks push by pulling all bookmarks2 items (but only if we want to use bookmarks2 instead of old-style boommarks) + //don't use [self updateBookmarks] to not update anything (e.g. readd a bookmark removed by another client) + if(!_hasFetchedBookmarks && _account.connectionProperties.supportsBookmarksCompat) + [_account.pubsub fetchNode:@"urn:xmpp:bookmarks:1" from:_account.connectionProperties.identity.jid withItemsList:nil andHandler:$newHandler(MLPubSubProcessor, bookmarks2Handler, $ID(type, @"publish"))]; + } +} + +-(void) handleSentMessage:(NSNotification*) notification +{ + XMPPMessage* msg = notification.userInfo[@"message"]; + NSString* callUiHandlerFor = nil; + + //check if this is a direct invite + if([msg check:@"/{jabber:client}message/{jabber:x:conference}x@jid"]) + callUiHandlerFor = [msg findFirst:@"/{jabber:client}message/{jabber:x:conference}x@jid"]; + + //check for muc subject change + if([msg check:@"/{jabber:client}message/subject"]) + callUiHandlerFor = msg.toUser; + + if(callUiHandlerFor != nil) + [self callSuccessUIHandlerForMuc:callUiHandlerFor]; +} + +-(BOOL) isCreating:(NSString*) room +{ + @synchronized(_stateLockObject) { + return _creating[room] != nil; + } +} + +-(BOOL) isJoining:(NSString*) room +{ + @synchronized(_stateLockObject) { + return _joining[room] != nil; + } +} + +-(BOOL) incrementNameChange:(NSString*) room +{ + @synchronized(_stateLockObject) { + if(!_changingName[room]) + { + _changingName[room] = @1; + return YES; + } + _changingName[room] = @(((NSNumber*)_changingName[room]).integerValue + 1); + return NO; + } +} + +-(BOOL) decrementNameChange:(NSString*) room +{ + @synchronized(_stateLockObject) { + if(_changingName[room] == nil) + return YES; + NSInteger oldValue = ((NSNumber*)_changingName[room]).integerValue; + _changingName[room] = @(max(0, oldValue - 1)); + if(oldValue == 0) + return YES; + return NO; + } +} + +-(void) addUIHandler:(monal_id_block_t) handler forMuc:(NSString*) room +{ + //this will replace the old handler + @synchronized(_stateLockObject) { + DDLogVerbose(@"Adding ui handler for muc: %@", room); + _uiHandler[room] = handler; + } +} + +-(void) removeUIHandlerForMuc:(NSString*) room +{ + @synchronized(_stateLockObject) { + [_uiHandler removeObjectForKey:room]; + } +} + +-(monal_id_block_t) getUIHandlerForMuc:(NSString*) room +{ + @synchronized(_stateLockObject) { + return _uiHandler[room]; + } +} + +-(void) processPresence:(XMPPPresence*) presenceNode +{ + //check for nickname conflict while joining and retry with underscore added to the end + if([self isJoining:presenceNode.fromUser] && [presenceNode findFirst:@"//error/{urn:ietf:params:xml:ns:xmpp-stanzas}conflict"]) + { + //load old nickname from db, add underscore and write it back to db so that it can be used by our next join + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:presenceNode.fromUser forAccount:_account.accountID]; + nick = [NSString stringWithFormat:@"%@_", nick]; + [[DataLayer sharedInstance] initMuc:presenceNode.fromUser forAccountID:_account.accountID andMucNick:nick]; + + //try to join again + DDLogInfo(@"Retrying muc join of %@ with new nick (appended underscore): %@", presenceNode.fromUser, nick); + [self removeRoomFromJoining:presenceNode.fromUser]; + [self sendJoinPresenceFor:presenceNode.fromUser]; + return; + } + + //check for all other errors (these can happen if the muc is discoverable but joining somehow fails nonetheless like with biboumi) + if([presenceNode check:@"//error"]) + { + DDLogError(@"Got transient muc error presence of %@: %@", presenceNode.fromUser, [presenceNode findFirst:@"error"]); + [self removeRoomFromJoining:presenceNode.fromUser]; + + //do nothing: the error is only temporary (a s2s problem etc.), a muc ping will retry the join + //this will keep the entry in local bookmarks table and remote bookmars + //--> retry the join on mucPing or full login without smacks resume + //this will also keep the buddy list entry + //--> allow users to read the last messages before the muc got broken + + //only display an error banner, no notification (this is only temporary) + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Temporary failure to enter Group/Channel: %@", @""), presenceNode.fromUser] forMuc:presenceNode.fromUser withNode:presenceNode andIsSevere:NO]; + return; + } + else if([presenceNode check:@"/"]) + { + DDLogError(@"Got permanent muc error presence of %@: %@", presenceNode.fromUser, [presenceNode findFirst:@"error"]); + [self removeRoomFromJoining:presenceNode.fromUser]; + + //delete muc from favorites table to be sure we don't try to rejoin it and update bookmarks afterwards (to make sure this muc isn't accidentally left in our boomkmarks) + //make sure to update remote bookmarks, even if updateBookmarks == NO + //keep buddy list entry to allow users to read the last messages before the muc got deleted/broken + [self deleteMuc:presenceNode.fromUser withBookmarksUpdate:YES keepBuddylistEntry:YES]; + + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to enter Group/Channel %@", @""), presenceNode.fromUser] forMuc:presenceNode.fromUser withNode:presenceNode andIsSevere:YES]; + return; + } + + //handle presences from muc bare jid + if(presenceNode.fromResource == nil) + { + DDLogVerbose(@"Got muc presence from bare jid: %@", presenceNode.from); + //check vcard hash + NSString* avatarHash = [presenceNode findFirst:@"{vcard-temp:x:update}x/photo#"]; + NSString* currentHash = [[DataLayer sharedInstance] getAvatarHashForContact:presenceNode.fromUser andAccount:_account.accountID]; + DDLogVerbose(@"Checking if avatar hash in presence '%@' equals stored hash '%@'...", avatarHash, currentHash); + if(avatarHash != nil && !(currentHash && [avatarHash isEqualToString:currentHash])) + { + DDLogInfo(@"Got new muc avatar hash '%@' for muc %@, fetching new image via vcard-temp...", avatarHash, presenceNode.fromUser); + [self fetchAvatarForRoom:presenceNode.fromUser]; + } + else if(avatarHash == nil && currentHash != nil && ![currentHash isEqualToString:@""]) + { + [[MLImageManager sharedInstance] setIconForContact:[MLContact createContactFromJid:presenceNode.fromUser andAccountID:_account.accountID] WithData:nil]; + [[DataLayer sharedInstance] setAvatarHash:@"" forContact:presenceNode.fromUser andAccount:_account.accountID]; + //delete cache to make sure the image will be regenerated + [[MLImageManager sharedInstance] purgeCacheForContact:presenceNode.fromUser andAccount:_account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:presenceNode.fromUser andAccountID:_account.accountID] + }]; + DDLogInfo(@"Avatar of muc '%@' deleted successfully", presenceNode.fromUser); + } + else + { + DDLogInfo(@"Avatar hash '%@' of muc %@ did not change, not updating avatar...", avatarHash, presenceNode.fromUser); + } + } + //handle reflected presences + else + { + DDLogVerbose(@"Got muc presence from full jid: %@", presenceNode.from); + + //don't handle this error if we ourselves are destroying this room + BOOL isDestroying = NO; + @synchronized(_stateLockObject) { + isDestroying = [_destroying containsObject:presenceNode.fromUser]; + } + if(!isDestroying) + { + //extract info if present (use an empty dict if no info is present) + NSMutableDictionary* item = [[presenceNode findFirst:@"{http://jabber.org/protocol/muc#user}x/item@@"] mutableCopy]; + if(!item) + item = [NSMutableDictionary new]; + + //update jid to be a bare jid and add muc nick to our dict + if(item[@"jid"]) + item[@"jid"] = [HelperTools splitJid:item[@"jid"]][@"user"]; + item[@"nick"] = presenceNode.fromResource; + if([_roomFeatures[presenceNode.fromUser] containsObject:@"urn:xmpp:occupant-id:0"]) + item[@"occupant_id"] = [presenceNode findFirst:@"{urn:xmpp:occupant-id:0}occupant-id@id"]; + + //handle participant updates + if([presenceNode check:@"/"] || item[@"affiliation"] == nil) + { + DDLogVerbose(@"Removing participant from muc(%@): %@", presenceNode.fromUser, item); + [[DataLayer sharedInstance] removeParticipant:item fromMuc:presenceNode.fromUser forAccountID:_account.accountID]; + } + else + { + DDLogVerbose(@"Adding participant from muc(%@): %@", presenceNode.fromUser, item); + [[DataLayer sharedInstance] addParticipant:item toMuc:presenceNode.fromUser forAccountID:_account.accountID]; + } + + //handle members updates (publishing the changes in members/participants is already handled by handleMembersListUpdate + //--> only publish if we don't call handleMembersListUpdate + if(item[@"jid"] != nil) + [self handleMembersListUpdate:[presenceNode find:@"{http://jabber.org/protocol/muc#user}x/item@@"] forMuc:presenceNode.fromUser]; + else + { + DDLogDebug(@"Publishing participants list update..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMucParticipantsAndMembersUpdated object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:presenceNode.fromUser andAccountID:_account.accountID] + }]; + } + } + else + DDLogDebug(@"Ignoring unavailable presences of room being destroyed by us..."); + + //handle muc status codes in reflected presences + //this MUST be done after the above code to make sure the db correctly reflects our membership/participant status + if([presenceNode check:@"/{jabber:client}presence/{http://jabber.org/protocol/muc#user}x/status@code"]) + [self handleStatusCodes:presenceNode]; + } +} + +-(BOOL) processMessage:(XMPPMessage*) messageNode +{ + //handle member list updates of offline members (useful for members-only mucs) + if([messageNode check:@"{http://jabber.org/protocol/muc#user}x/item"]) + [self handleMembersListUpdate:[messageNode find:@"{http://jabber.org/protocol/muc#user}x/item@@"] forMuc:messageNode.fromUser]; + + //handle muc status codes + [self handleStatusCodes:messageNode]; + + //handle mediated invites + if([messageNode check:@"{http://jabber.org/protocol/muc#user}x/invite"]) + { + //ignore outgoing carbon copies or mam results + if(![messageNode.toUser isEqualToString:_account.connectionProperties.identity.jid]) + return YES; //stop processing in MLMessageProcessor and ignore this invite + + NSString* invitedMucJid = [HelperTools splitJid:[messageNode findFirst:@"{http://jabber.org/protocol/muc#user}x/invite@from"]][@"user"]; + if(invitedMucJid == nil) + { + DDLogError(@"mediated inivite does not include a MUC jid, ignoring invite"); + return YES; + } + MLContact* inviteFrom = [MLContact createContactFromJid:invitedMucJid andAccountID:_account.accountID]; + DDLogInfo(@"Got mediated muc invite from %@ for %@...", inviteFrom, messageNode.fromUser); + if(![[HelperTools defaultsDB] boolForKey: @"allowNonRosterContacts"] && !inviteFrom.isSubscribedFrom) + { + DDLogWarn(@"Ignoring invite from %@, this jid isn't at least marked as susbscribedFrom in our roster...", inviteFrom); + return YES; //don't process this further + } + DDLogInfo(@"--> joinging %@...", messageNode.fromUser); + [self sendDiscoQueryFor:messageNode.fromUser withJoin:YES andBookmarksUpdate:YES]; + return YES; //stop processing in MLMessageProcessor + } + + //handle direct invites + if([messageNode check:@"{jabber:x:conference}x@jid"] && [[messageNode findFirst:@"{jabber:x:conference}x@jid"] length] > 0) + { + //ignore outgoing carbon copies or mam results + if(![messageNode.toUser isEqualToString:_account.connectionProperties.identity.jid]) + return YES; //stop processing in MLMessageProcessor and ignore this invite + + MLContact* inviteFrom = [MLContact createContactFromJid:messageNode.fromUser andAccountID:_account.accountID]; + DDLogInfo(@"Got direct muc invite from %@ for %@ --> joining...", inviteFrom, [messageNode findFirst:@"{jabber:x:conference}x@jid"]); + if(![[HelperTools defaultsDB] boolForKey: @"allowNonRosterContacts"] && !inviteFrom.isSubscribedFrom) + { + DDLogWarn(@"Ignoring invite from %@, this jid isn't at least marked as susbscribedFrom in our roster...", inviteFrom); + return YES; //don't process this further + } + DDLogInfo(@"--> joinging %@...", [messageNode findFirst:@"{jabber:x:conference}x@jid"]); + [self sendDiscoQueryFor:[messageNode findFirst:@"{jabber:x:conference}x@jid"] withJoin:YES andBookmarksUpdate:YES]; + return YES; //stop processing in MLMessageProcessor + } + + //continue processing in MLMessageProcessor + return NO; +} + +-(void) handleMembersListUpdate:(NSArray*) items forMuc:(NSString*) mucJid; +{ + //check if this is still a muc and ignore the members list update, if not + if([[DataLayer sharedInstance] isBuddyMuc:mucJid forAccount:_account.accountID]) + { + DDLogInfo(@"Handling members list update for %@: %@", mucJid, items); + for(NSDictionary* entry in items) + { + NSMutableDictionary* item = [entry mutableCopy]; + if(!item || item[@"jid"] == nil) + { + DDLogDebug(@"Ignoring empty item/jid: %@", item); + continue; //ignore empty items or items without a jid + } + + //update jid to be a bare jid + item[@"jid"] = [HelperTools splitJid:item[@"jid"]][@"user"]; + +#ifndef DISABLE_OMEMO + BOOL isTypeGroup = [[[DataLayer sharedInstance] getMucTypeOfRoom:mucJid andAccount:_account.accountID] isEqualToString:kMucTypeGroup]; +#endif + + if(item[@"affiliation"] == nil || [kMucAffiliationNone isEqualToString:item[@"affiliation"]]) + { + DDLogVerbose(@"Removing member '%@' from muc '%@'...", item[@"jid"], mucJid); + [[DataLayer sharedInstance] removeMember:item fromMuc:mucJid forAccountID:_account.accountID]; +#ifndef DISABLE_OMEMO + if(isTypeGroup == YES) + [_account.omemo checkIfSessionIsStillNeeded:item[@"jid"] isMuc:NO]; +#endif// DISABLE_OMEMO + } + else + { + DDLogVerbose(@"Adding member '%@' to muc '%@'...", item[@"jid"], mucJid); + [[DataLayer sharedInstance] addMember:item toMuc:mucJid forAccountID:_account.accountID]; +#ifndef DISABLE_OMEMO + if(isTypeGroup == YES) + [_account.omemo subscribeAndFetchDevicelistIfNoSessionExistsForJid:item[@"jid"]]; +#endif// DISABLE_OMEMO + } + } + + DDLogDebug(@"Publishing new memberslist..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalMucParticipantsAndMembersUpdated object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:mucJid andAccountID:_account.accountID] + }]; + } + else + DDLogWarn(@"Ignoring handleMembersListUpdate for %@, MUC not in buddylist", mucJid); +} + +-(void) configureMuc:(NSString*) roomJid withMandatoryOptions:(NSDictionary*) mandatoryOptions andOptionalOptions:(NSDictionary*) optionalOptions deletingMucOnError:(BOOL) deleteOnError andJoiningMucOnSuccess:(BOOL) joinOnSuccess +{ + DDLogInfo(@"Fetching room config form: %@", roomJid); + XMPPIQ* configFetchNode = [[XMPPIQ alloc] initWithType:kiqGetType to:roomJid]; + [configFetchNode setGetRoomConfig]; + [_account sendIq:configFetchNode withHandler:$newHandlerWithInvalidation(self, handleRoomConfigForm, handleRoomConfigFormInvalidation, $ID(roomJid), $ID(mandatoryOptions), $ID(optionalOptions), $BOOL(deleteOnError), $BOOL(joinOnSuccess))]; +} + +$$instance_handler(handleRoomConfigFormInvalidation, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, roomJid), $$ID(NSDictionary*, mandatoryOptions), $$ID(NSDictionary*, optionalOptions), $$BOOL(deleteOnError)) + [self decrementNameChange:roomJid]; + if(deleteOnError) + { + DDLogError(@"Config form fetch failed, removing muc '%@' from _creating...", roomJid); + [self removeRoomFromCreating:roomJid]; + [self deleteMuc:roomJid withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + else + DDLogError(@"Config form fetch failed for muc '%@'!", roomJid); + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Could fetch room config form for '%@': timeout", @""), roomJid] forMuc:roomJid withNode:nil andIsSevere:YES]; +$$ + +$$instance_handler(handleRoomConfigForm, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, roomJid), $$ID(NSDictionary*, mandatoryOptions), $$ID(NSDictionary*, optionalOptions), $$BOOL(deleteOnError), $$BOOL(joinOnSuccess)) + MLAssert([iqNode.fromUser isEqualToString:roomJid], @"Room config form response jid not matching query jid!", (@{ + @"iqNode.fromUser": [NSString stringWithFormat:@"%@", iqNode.fromUser], + @"roomJid": [NSString stringWithFormat:@"%@", roomJid], + })); + if([iqNode check:@"/"]) + { + DDLogError(@"Failed to fetch room config form for '%@': %@", roomJid, [iqNode findFirst:@"error"]); + [self decrementNameChange:roomJid]; + if(deleteOnError) + { + [self removeRoomFromCreating:roomJid]; + [self deleteMuc:roomJid withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to fetch room config form for '%@'", @""), roomJid] forMuc:roomJid withNode:iqNode andIsSevere:YES]; + return; + } + + XMPPDataForm* dataForm = [[iqNode findFirst:@"{http://jabber.org/protocol/muc#owner}query/\\{http://jabber.org/protocol/muc#roomconfig}form\\"] copy]; + if(dataForm == nil) + { + DDLogError(@"Got empty room config form for '%@'!", roomJid); + [self decrementNameChange:roomJid]; + if(deleteOnError) + { + [self removeRoomFromCreating:roomJid]; + [self deleteMuc:roomJid withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Got empty room config form for '%@'", @""), roomJid] forMuc:roomJid withNode:nil andIsSevere:YES]; + return; + } + //these config options are mandatory and configure the room to be a group --> non anonymous, members only (and persistent) + for(NSString* option in mandatoryOptions) + { + if([dataForm getField:option] == nil) + { + DDLogError(@"Could not configure room '%@' to be a groupchat: config option '%@' not available!", roomJid, option); + [self decrementNameChange:roomJid]; + if(deleteOnError) + { + [self removeRoomFromCreating:roomJid]; + [self deleteMuc:roomJid withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Could not configure (new) group '%@': config option '%@' not available!", @""), roomJid, option] forMuc:roomJid withNode:nil andIsSevere:YES]; + return; + } + else + dataForm[option] = mandatoryOptions[option]; + } + + //these config options are optional but most of them should be supported by all modern servers + for(NSString* option in optionalOptions) + { + if(dataForm[option]) + dataForm[option] = optionalOptions[option]; + else + DDLogWarn(@"Ignoring optional config option for room '%@': %@", roomJid, option); + } + + //reconfigure the room + dataForm.type = @"submit"; + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType to:roomJid]; + [query setRoomConfig:dataForm]; + [_account sendIq:query withHandler:$newHandlerWithInvalidation(self, handleRoomConfigResult, handleRoomConfigResultInvalidation, $ID(roomJid), $ID(mandatoryOptions), $ID(optionalOptions), $BOOL(deleteOnError), $BOOL(joinOnSuccess))]; +$$ + +$$instance_handler(handleRoomConfigResultInvalidation, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, roomJid), $$ID(NSDictionary*, mandatoryOptions), $$ID(NSDictionary*, optionalOptions), $$BOOL(deleteOnError)) + [self decrementNameChange:roomJid]; + if(deleteOnError) + { + DDLogError(@"Config form submit failed, removing muc '%@' from _creating...", roomJid); + [self removeRoomFromCreating:roomJid]; + [self deleteMuc:roomJid withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + else + DDLogError(@"Config form submit failed for muc '%@'!", roomJid); + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Could not configure group '%@': timeout", @""), roomJid] forMuc:roomJid withNode:nil andIsSevere:YES]; +$$ + +$$instance_handler(handleRoomConfigResult, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, roomJid), $$ID(NSDictionary*, mandatoryOptions), $$ID(NSDictionary*, optionalOptions), $$BOOL(deleteOnError), $$BOOL(joinOnSuccess)) + if([iqNode check:@"/"]) + { + DDLogError(@"Failed to submit room config form of '%@': %@", roomJid, [iqNode findFirst:@"error"]); + [self decrementNameChange:roomJid]; + if(deleteOnError) + { + [self removeRoomFromCreating:roomJid]; + [self deleteMuc:roomJid withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Could not configure group '%@'", @""), roomJid] forMuc:roomJid withNode:iqNode andIsSevere:YES]; + return; + } + MLAssert([iqNode.fromUser isEqualToString:roomJid], @"Room config form response jid not matching query jid!", (@{ + @"iqNode.fromUser": [NSString stringWithFormat:@"%@", iqNode.fromUser], + @"roomJid": [NSString stringWithFormat:@"%@", roomJid], + })); + + //don't call success handler if we are only "half-joined" (see comments below for what that means) + if(joinOnSuccess) + { + //group is now properly configured and we are joined, but all the code handling a proper join was not run + //--> join again to make sure everything is sane + [self join:roomJid]; + } + else + [self callSuccessUIHandlerForMuc:iqNode.fromUser]; +$$ + +-(void) handleStatusCodes:(XMPPStanza*) node +{ + NSSet* presenceCodes = [[NSSet alloc] initWithArray:[node find:@"/{jabber:client}presence/{http://jabber.org/protocol/muc#user}x/status@code|int"]]; + NSSet* messageCodes = [[NSSet alloc] initWithArray:[node find:@"/{jabber:client}message/{http://jabber.org/protocol/muc#user}x/status@code|int"]]; + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:node.fromUser forAccount:_account.accountID]; + + //handle status codes allowed in presences AND messages + NSMutableSet* unhandledStatusCodes = [NSMutableSet new]; + NSMutableSet* jointCodes = [presenceCodes mutableCopy]; + [jointCodes unionSet:messageCodes]; + BOOL selfPrecenceHandled = NO; + for(NSNumber* code in jointCodes) + switch([code intValue]) + { + //muc service changed our nick + case 210: + { + //check if we haven't joined already (this status code is only valid while entering a room) + if([self isJoining:node.fromUser]) + { + //update nick in database + DDLogInfo(@"Updating muc %@ nick in database to nick provided by server: '%@'...", node.fromUser, node.fromResource); + [[DataLayer sharedInstance] updateOwnNickName:node.fromResource forMuc:node.fromUser forAccount:_account.accountID]; + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:node.fromUser andAccountID:_account.accountID] + }]; + } + break; + } + //banned from room + case 301: + { + DDLogDebug(@"user '%@' got banned from room %@", node.fromResource, node.fromUser); + if([nick isEqualToString:node.fromResource]) + { + DDLogDebug(@"got banned from room %@", node.fromUser); + [self removeRoomFromJoining:node.fromUser]; + [self deleteMuc:node.fromUser withBookmarksUpdate:YES keepBuddylistEntry:NO]; + selfPrecenceHandled = YES; + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"You got banned from group/channel: %@", @""), node.fromUser] forMuc:node.fromUser withNode:node andIsSevere:YES]; + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:node.fromUser andAccountID:_account.accountID] + }]; + } + break; + } + //kicked from room + case 307: + { + /* + * To quote XEP-0045: + * Note: Some server implementations additionally include a 307 status code (signifying a 'kick', i.e. a forced ejection from the room). This is generally not advisable, as these types of disconnects may be frequent in the presence of poor network conditions and they are not linked to any user (e.g. moderator) action that the 307 code usually indicates. It is therefore recommended for the client to ignore the 307 code if a 333 status code is present. + */ + if(![jointCodes containsObject:@333]) + { + DDLogDebug(@"user '%@' got kicked from room %@", node.fromResource, node.fromUser); + if([nick isEqualToString:node.fromResource]) + { + DDLogDebug(@"got kicked from room %@", node.fromUser); + [self removeRoomFromJoining:node.fromUser]; + [self deleteMuc:node.fromUser withBookmarksUpdate:YES keepBuddylistEntry:NO]; + selfPrecenceHandled = YES; + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"You got kicked from group/channel: %@", @""), node.fromUser] forMuc:node.fromUser withNode:node andIsSevere:YES]; + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:node.fromUser andAccountID:_account.accountID] + }]; + } + } + else + DDLogWarn(@"Ignoring 307 status code because code 333 is present, too..."); + break; + } + //removed because of affiliation change + case 321: + { + //only handle this and rejoin, if we did not get removed from a members-only room + if(![jointCodes containsObject:@322]) + { + DDLogDebug(@"user '%@' got affiliation changed for room %@", node.fromResource, node.fromUser); + if([nick isEqualToString:node.fromResource]) + { + DDLogDebug(@"got own affiliation change for room %@", node.fromUser); + //check if we are still in the room (e.g. loss of membership status in public channel or admin to member degradation) + if([[DataLayer sharedInstance] getParticipantForNick:node.fromResource inRoom:node.fromUser forAccountID:_account.accountID] == nil) + { + DDLogInfo(@"Got removed from room..."); + [self removeRoomFromJoining:node.fromUser]; + [self deleteMuc:node.fromUser withBookmarksUpdate:YES keepBuddylistEntry:YES]; + selfPrecenceHandled = YES; + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"You got removed from group/channel: %@", @""), node.fromUser] forMuc:node.fromUser withNode:node andIsSevere:YES]; + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:node.fromUser andAccountID:_account.accountID] + }]; + } + } + } + break; + } + //removed because room is now members only (an we are not a member) + case 322: + { + DDLogDebug(@"user '%@' got removed from members-only room %@", node.fromResource, node.fromUser); + if([nick isEqualToString:node.fromResource]) + { + DDLogDebug(@"got removed from members-only room %@", node.fromUser); + [self removeRoomFromJoining:node.fromUser]; + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Kicked, because group/channel is now members-only: %@", @""), node.fromUser] forMuc:node.fromUser withNode:node andIsSevere:YES]; + [self deleteMuc:node.fromUser withBookmarksUpdate:YES keepBuddylistEntry:YES]; + selfPrecenceHandled = YES; + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:node.fromUser andAccountID:_account.accountID] + }]; + } + break; + } + //removed because of system shutdown + case 332: + { + DDLogDebug(@"user '%@' got removed from room %@ because of system shutdown", node.fromResource, node.fromUser); + if([nick isEqualToString:node.fromResource]) + { + DDLogDebug(@"got removed from room %@ because of system shutdown", node.fromUser); + [self removeRoomFromJoining:node.fromUser]; + selfPrecenceHandled = YES; + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Kicked from group/channel, because of system shutdown: %@", @""), node.fromUser] forMuc:node.fromUser withNode:node andIsSevere:YES]; + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:node.fromUser andAccountID:_account.accountID] + }]; + } + break; + } + default: + [unhandledStatusCodes addObject:code]; + } + + //handle presence stanzas + if(presenceCodes && [presenceCodes count]) + { + for(NSNumber* code in presenceCodes) + switch([code intValue]) + { + //room created and needs configuration now + case 100: + DDLogVerbose(@"This room is non-anonymous: everybody can see all jids..."); + break; + case 110: + break; //ignore self-presence status handled below + case 201: + { + if(![presenceCodes containsObject:@110]) + { + DDLogError(@"Got 'muc needs configuration' status code (201) without self-presence, ignoring!"); + break; + } + if(![self isCreating:node.fromUser]) + { + DDLogError(@"Got 'muc needs configuration' status code (201) without this muc currently being created, ignoring: %@", node.fromUser); + break; + } + + //now configure newly created locked room + [self configureMuc:node.fromUser withMandatoryOptions:_mandatoryGroupConfigOptions andOptionalOptions:_optionalGroupConfigOptions deletingMucOnError:YES andJoiningMucOnSuccess:YES]; + + //stop processing here to not trigger the "successful join" code below + //we will trigger this code by a "second" join presence once the room was created and is not locked anymore + return; + break; + } + default: + //only log errors for status codes not already handled by our joint handling above + if([unhandledStatusCodes containsObject:code]) + DDLogWarn(@"Got unhandled muc status code in presence from %@: %@", node.from, code); + break; + } + + //this is a self-presence (marking the end of the presence flood if we are in joining state) + //handle this code last because it may reset _joining + if([presenceCodes containsObject:@110] && !selfPrecenceHandled) + { + //check if we have joined already (we handle only non-joining self-presences here) + //joining self-presences are handled below + if(![self isJoining:node.fromUser]) + { + DDLogInfo(@"Got non-joining muc presence for %@...", node.fromUser); + + //handle muc destroys, but ignore other non-joining self-presences for now + //(normally these have an additional status code that was already handled in the switch statement above + if([node check:@"//{http://jabber.org/protocol/muc#user}x/destroy"]) + { + //don't handle this error if we ourselves are destroying this room + BOOL isDestroying = NO; + @synchronized(_stateLockObject) { + isDestroying = [_destroying containsObject:node.fromUser]; + } + if(!isDestroying) + { + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Group/Channel got destroyed: %@", @""), node.fromUser] forMuc:node.fromUser withNode:node andIsSevere:YES]; + [self deleteMuc:node.fromUser withBookmarksUpdate:YES keepBuddylistEntry:YES]; + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:node.fromUser andAccountID:_account.accountID] + }]; + } + } + } + else + { + DDLogInfo(@"Successfully joined muc %@...", node.fromUser); + + //we are joined now, remove from joining list + [self removeRoomFromJoining:node.fromUser]; + + //we joined successfully --> add muc to our favorites (this will use the already up to date nick from buddylist db table) + //and update bookmarks if this was the first time we joined this muc + [[DataLayer sharedInstance] addMucFavorite:node.fromUser forAccountID:_account.accountID andMucNick:nil]; + @synchronized(_stateLockObject) { + DDLogVerbose(@"_firstJoin set: %@\n_noUpdateBookmarks set: %@", _firstJoin, _noUpdateBookmarks); + //only update bookmarks on first join AND if not requested otherwise (batch join etc.) + if([_firstJoin containsObject:node.fromUser] && ![_noUpdateBookmarks containsObject:node.fromUser]) + [self updateBookmarks]; + [_firstJoin removeObject:node.fromUser]; + [_noUpdateBookmarks removeObject:node.fromUser]; + } + + //update own occupant-id in buddylist + NSString* occupantId = nil; + @synchronized(_stateLockObject) { + if([_roomFeatures[node.fromUser] containsObject:@"urn:xmpp:occupant-id:0"]) + occupantId = [node findFirst:@"{urn:xmpp:occupant-id:0}occupant-id@id"]; + } + [[DataLayer sharedInstance] updateOwnOccupantID:occupantId forMuc:node.fromUser onAccountID:_account.accountID]; + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:node.fromUser andAccountID:_account.accountID] + }]; + + [self logMembersOfMuc:node.fromUser]; + + //load members/admins/owners list (this has to be done *after* joining the muc to not get auth errors) + DDLogInfo(@"Querying outcast/member/admin/owner lists for muc %@...", node.fromUser); + for(NSString* type in @[kMucAffiliationOutcast, kMucAffiliationMember, kMucAffiliationAdmin, kMucAffiliationOwner]) + { + XMPPIQ* discoInfo = [[XMPPIQ alloc] initWithType:kiqGetType to:node.fromUser]; + [discoInfo setMucListQueryFor:type]; + [_account sendIq:discoInfo withHandler:$newHandler(self, handleMembersList, $ID(type))]; + } + + [self callSuccessUIHandlerForMuc:node.fromUser]; + + //MAYBE TODO: send out notification indicating we joined that room + + //query muc-mam for new messages + BOOL supportsMam = NO; + @synchronized(_stateLockObject) { + if(_roomFeatures[node.fromUser] && [_roomFeatures[node.fromUser] containsObject:@"urn:xmpp:mam:2"]) + supportsMam = YES; + } + if(supportsMam) + { + DDLogInfo(@"Muc %@ supports mam:2...", node.fromUser); + + //query mam since last received stanza ID because we could not resume the smacks session + //(we would not have landed here if we were able to resume the smacks session) + //this will do a catchup of everything we might have missed since our last connection + //we possibly receive sent messages, too (this will update the stanzaid in database and gets deduplicate by messageid, + //which is guaranteed to be unique (because monal uses uuids for outgoing messages) + NSString* lastStanzaId = [[DataLayer sharedInstance] lastStanzaIdForMuc:node.fromUser andAccount:_account.accountID]; + [_account delayIncomingMessageStanzasForArchiveJid:node.fromUser]; + XMPPIQ* mamQuery = [[XMPPIQ alloc] initWithType:kiqSetType to:node.fromUser]; + if(lastStanzaId) + { + DDLogInfo(@"Querying muc mam:2 archive after stanzaid '%@' for catchup", lastStanzaId); + [mamQuery setMAMQueryAfter:lastStanzaId]; + [_account sendIq:mamQuery withHandler:$newHandler(self, handleCatchup, $BOOL(secondTry, NO))]; + } + else + { + DDLogInfo(@"Querying muc mam:2 archive for latest stanzaid to prime database"); + [mamQuery setMAMQueryForLatestId]; + [_account sendIq:mamQuery withHandler:$newHandler(self, handleMamResponseWithLatestId)]; + } + } + + //we don't need to force saving of our new state because once this incoming presence gets counted by smacks the whole state will be saved + } + } + } + //handle message stanzas + else if(messageCodes && [messageCodes count]) + { + for(NSNumber* code in messageCodes) + switch([code intValue]) + { + //config changes + case 102: + case 103: + case 104: + /* + * If room logging is now enabled, status code 170. + * If room logging is now disabled, status code 171. + * If the room is now non-anonymous, status code 172. + * If the room is now semi-anonymous, status code 173. + */ + case 170: + case 171: + case 172: + case 173: + { + DDLogInfo(@"Muc config of %@ changed, sending new disco info query to reload muc config...", node.fromUser); + [self sendDiscoQueryFor:node.from withJoin:NO andBookmarksUpdate:NO]; + break; + } + default: + //only log errors for status codes not already handled by our joint handling above + if([unhandledStatusCodes containsObject:code]) + DDLogWarn(@"Got unhandled muc status code in message from %@: %@", node.from, code); + break; + } + } +} + +$$instance_handler(handleCreateTimeout, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, room)) + if(![self isCreating:room]) + { + DDLogError(@"Got room create idle timeout but not creating group, ignoring: %@", room); + return; + } + DDLogWarn(@"Timeout while creating muc '%@'...", room); + [self removeRoomFromCreating:room]; + [self deleteMuc:room withBookmarksUpdate:NO keepBuddylistEntry:NO]; + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Could not create group '%@': timeout", @""), room] forMuc:room withNode:nil andIsSevere:YES]; +$$ + +-(NSString* _Nullable) generateMucJid +{ + NSString* mucServer = nil; + for(NSString* jid in _account.connectionProperties.conferenceServers) + { + if([_account.connectionProperties.conferenceServers[jid] check:@"identity"]) + { + mucServer = jid; + break; + } + } + if(mucServer == nil) + return nil; + NSString* node = [self generateSpeakableGroupNode]; + node = [node stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet].lowercaseString; + NSString* room = [[NSString stringWithFormat:@"%@@%@", node, mucServer] lowercaseString]; + return room; +} + +-(NSString* _Nullable) createGroup:(NSString*) room +{ + if([[DataLayer sharedInstance] isBuddyMuc:room forAccount:_account.accountID]) + { + DDLogWarn(@"Cannot create muc already existing in our buddy list, checking if we are still joined and join if needed..."); + [self ping:room]; + return nil; + } + + //remove old non-muc contact from contactlist (we don't want mucs as normal contacts on our (server) roster and shadowed in monal by the real muc contact) + NSDictionary* existingContactDict = [[DataLayer sharedInstance] contactDictionaryForUsername:room forAccount:_account.accountID]; + if(existingContactDict != nil) + { + MLContact* existingContact = [MLContact createContactFromJid:room andAccountID:_account.accountID]; + DDLogVerbose(@"CreateMUC: Removing already existing contact (%@) having raw db dict: %@", existingContact, existingContactDict); + [_account removeFromRoster:existingContact]; + } + //add new muc buddy (potentially deleting a non-muc buddy having the same jid) + NSString* nick = [self calculateNickForMuc:room]; + DDLogInfo(@"CreateMUC: Adding new muc %@ using nick '%@' to buddylist...", room, nick); + [[DataLayer sharedInstance] initMuc:room forAccountID:_account.accountID andMucNick:nick]; + + DDLogInfo(@"Trying to create muc '%@' with nick '%@' on account %@...", room, nick, _account); + @synchronized(_stateLockObject) { + //add room to "currently creating" list (and remove any present idle timer for this room) + [[DataLayer sharedInstance] delIdleTimerWithId:_creating[room]]; + //add idle timer to display error if we did not receive the reflected create presence after 30 idle seconds + //this will make sure the spinner ui will not spin indefinitely when adding a channel via ui + NSNumber* timerId = [[DataLayer sharedInstance] addIdleTimerWithTimeout:@30 andHandler:$newHandler(self, handleCreateTimeout, $ID(room)) onAccountID:_account.accountID]; + _creating[room] = timerId; + //we don't need to force saving of our new state because once this outgoing create presence gets handled by smacks the whole state will be saved + } + XMPPPresence* presence = [XMPPPresence new]; + [presence createRoom:room withNick:nick]; + [_account send:presence]; + + return room; +} + +-(void) destroyRoom:(NSString*) room +{ + MLAssert([[DataLayer sharedInstance] isBuddyMuc:room forAccount:_account.accountID], @"Cannot destroy non-muc!", (@{@"room": room})); + + @synchronized(_stateLockObject) { + [_destroying addObject:room]; + } + + XMPPIQ* iqNode = [[XMPPIQ alloc] initWithType:kiqSetType to:room]; + [iqNode addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/muc#owner" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"destroy" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"reason" andData:@"Groupchat got destroyed"] + ] andData:nil], + ] andData:nil]]; + [_account sendIq:iqNode withHandler:$newHandlerWithInvalidation(self, handleRoomDestroyResult, handleRoomDestroyResultInvalidation, $ID(room))]; +} + +$$instance_handler(handleRoomDestroyResultInvalidation, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, room)) + DDLogError(@"Could not destroy room '%@' on account %@: invalidation called", room, account); + @synchronized(_stateLockObject) { + [_destroying removeObject:room]; + } + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to destroy group/channel '%@': timeout", @""), room] forMuc:room withNode:nil andIsSevere:YES]; +$$ + +$$instance_handler(handleRoomDestroyResult, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, room)) + @synchronized(_stateLockObject) { + [_destroying removeObject:room]; + } + if([iqNode check:@"/"]) + { + DDLogError(@"Failed to destroy room '%@' on account %@: %@", room, account, [iqNode findFirst:@"error"]); + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to destroy group/channel '%@'", @""), room] forMuc:room withNode:iqNode andIsSevere:YES]; + return; + } + + DDLogInfo(@"Successfully destroyed room '%@' on account %@", room, account); + if([self getUIHandlerForMuc:room] != nil) + { + [self callSuccessUIHandlerForMuc:room withCallback:^{ + //don't even keep our bookmark in this case + [self deleteMuc:room withBookmarksUpdate:YES keepBuddylistEntry:NO]; + }]; + } + else + { + //don't even keep our bookmark in this case + //this will handled by the ui handler callback if the ui was used to destroy this room and must be handled here otherwise + [self deleteMuc:room withBookmarksUpdate:YES keepBuddylistEntry:NO]; + } +$$ + +-(void) join:(NSString*) room +{ + [self sendDiscoQueryFor:room withJoin:YES andBookmarksUpdate:YES]; +} + +-(void) leave:(NSString*) room withBookmarksUpdate:(BOOL) updateBookmarks keepBuddylistEntry:(BOOL) keepBuddylistEntry +{ + room = [room lowercaseString]; + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:_account.accountID]; + if(nick == nil) + { + DDLogError(@"Cannot leave room '%@' on account %@ because nick is nil!", room, _account); + return; + } + @synchronized(_stateLockObject) { + if(_joining[room] != nil) + { + DDLogInfo(@"Aborting join of room '%@' on account %@", room, _account); + [self removeRoomFromJoining:room]; + } + } + DDLogInfo(@"Leaving room '%@' on account %@ using nick '%@'...", room, _account, nick); + //send unsubscribe even if we are not fully joined (join aborted), just to make sure we *really* leave ths muc + XMPPPresence* presence = [XMPPPresence new]; + [presence leaveRoom:room withNick:nick]; + [_account send:presence]; + + //delete muc from favorites table and update bookmarks if requested + [self deleteMuc:room withBookmarksUpdate:updateBookmarks keepBuddylistEntry:keepBuddylistEntry]; +} + +-(void) sendDiscoQueryFor:(NSString*) roomJid withJoin:(BOOL) join andBookmarksUpdate:(BOOL) updateBookmarks +{ + if(roomJid == nil || _account == nil) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Room jid or account must not be nil!" userInfo:nil]; + roomJid = [roomJid lowercaseString]; + DDLogInfo(@"Querying disco for muc %@...", roomJid); + //mark room as "joining" as soon as possible to make sure we can handle join "aborts" (e.g. when processing bookmark updates while a joining disco query is already in flight) + //this will fix race condition that makes us join a muc directly after it got removed from our favorites table and leaved through a bookmark update + if(join) + { + @synchronized(_stateLockObject) { + //don't join twice + if(_joining[roomJid] != nil) + { + DDLogInfo(@"Already joining muc %@, not doing it twice", roomJid); + return; + } + //add room to "currently joining" list (without any idle timer yet, because the iq handling will timeout the disco iq already) + _joining[roomJid] = @(-1); //TODO + //we don't need to force saving of our new state because once this outgoing iq query gets handled by smacks the whole state will be saved + } + } + XMPPIQ* discoInfo = [[XMPPIQ alloc] initWithType:kiqGetType to:roomJid]; + [discoInfo setDiscoInfoNode]; + [_account sendIq:discoInfo withHandler:$newHandlerWithInvalidation(self, handleDiscoResponse, handleDiscoResponseInvalidation, $ID(roomJid), $BOOL(join), $BOOL(updateBookmarks))]; +} + +-(void) pingAllMucs +{ + if([[NSDate date] timeIntervalSinceDate:_lastPing] < MUC_PING) + { + DDLogInfo(@"Not pinging all mucs, last ping was less than %d seconds ago: %@", MUC_PING, _lastPing); + return; + } + for(NSString* room in [[DataLayer sharedInstance] listMucsForAccount:_account.accountID]) + [self ping:room withLastPing:_lastPing]; + _lastPing = [NSDate date]; +} + +-(void) ping:(NSString*) roomJid +{ + [self ping:roomJid withLastPing:nil]; +} + +-(void) ping:(NSString*) roomJid withLastPing:(NSDate* _Nullable) lastPing +{ + if(![[DataLayer sharedInstance] isBuddyMuc:roomJid forAccount:_account.accountID]) + { + DDLogWarn(@"Tried to muc-ping non-muc jid '%@', trying to join regularily with disco...", roomJid); + [self removeRoomFromJoining:roomJid]; + //this will check if this jid really is not a muc and delete it fom favorites and bookmarks, if not (and join normally if it turns out is a muc after all) + [self sendDiscoQueryFor:roomJid withJoin:YES andBookmarksUpdate:YES]; + return; + } + + XMPPIQ* ping = [[XMPPIQ alloc] initWithType:kiqGetType to:roomJid]; + ping.toResource = [[DataLayer sharedInstance] ownNickNameforMuc:roomJid forAccount:_account.accountID]; + [ping setPing]; + //we don't need to handle this across smacks resumes or reconnects, because a new ping will be issued on the next smacks resume + //(and full reconnets will rejoin all mucs anyways) + [_account sendIq:ping withResponseHandler:^(XMPPIQ* result __unused) { + DDLogInfo(@"Muc ping returned: we are still connected to %@, everything is fine", roomJid); + } andErrorHandler:^(XMPPIQ* error) { + if(error == nil) + { + DDLogWarn(@"Ping handler for %@ got invalidated, aborting ping...", roomJid); + //make sure we try again without waiting another MUC_PING seconds, if possible (i.e. this ping was not triggered by ui) + if(lastPing != nil) + self->_lastPing = lastPing; + return; + } + DDLogWarn(@"%@", [HelperTools extractXMPPError:error withDescription:@"Muc ping returned error"]); + if([error check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}not-acceptable"]) + { + DDLogWarn(@"Ping failed with 'not-acceptable' --> we have to re-join %@", roomJid); + @synchronized(self->_stateLockObject) { + [self->_joining removeObjectForKey:roomJid]; + } + //check if muc is still in our favorites table before we try to join it (could be deleted by a bookmarks update just after we sent out our ping) + //this has to be done to avoid such a race condition that would otherwise re-add the muc back + if([self checkIfStillBookmarked:roomJid]) + [self sendDiscoQueryFor:roomJid withJoin:YES andBookmarksUpdate:YES]; + else + DDLogWarn(@"Not re-joining because muc %@ got removed from favorites table in the meantime", roomJid); + } + else if( + [error check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}service-unavailable"] || + [error check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}feature-not-implemented"] + ) + { + DDLogInfo(@"The client is joined to %@, but the pinged client does not implement XMPP Ping (XEP-0199) --> do nothing", roomJid); + } + else if([error check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}item-not-found"]) + { + DDLogInfo(@"The client is joined to %@, but the occupant just changed their name (e.g. initiated by a different client) --> do nothing", roomJid); + } + else if( + [error check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}remote-server-not-found"] || + [error check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}remote-server-timeout"] + ) + { + DDLogError(@"The remote server for room %@ is unreachable for unspecified reasons; this can be a temporary network failure or a server outage. No decision can be made based on this; Treat like a timeout --> do nothing", roomJid); + } + else + { + DDLogWarn(@"Any other error happened: The client is probably not joined to %@ any more. It should perform a re-join. --> we have to re-join", roomJid); + @synchronized(self->_stateLockObject) { + [self->_joining removeObjectForKey:roomJid]; + } + //check if muc is still in our favorites table before we try to join it (could be deleted by a bookmarks updae just after we sent out our ping) + //this has to be done to avoid such a race condition that would otherwise re-add the muc back + if([self checkIfStillBookmarked:roomJid]) + [self sendDiscoQueryFor:roomJid withJoin:YES andBookmarksUpdate:YES]; + else + DDLogWarn(@"Not re-joining %@ because this muc got removed from favorites table in the meantime", roomJid); + } + }]; +} + +-(void) inviteUser:(NSString*) jid inMuc:(NSString*) roomJid +{ + DDLogInfo(@"Directly inviting user '%@' to '%@'...", jid, roomJid); + XMPPMessage* directInviteMsg = [[XMPPMessage alloc] initWithType:kMessageNormalType to:jid]; + [directInviteMsg addChildNode:[[MLXMLNode alloc] initWithElement:@"x" andNamespace:@"jabber:x:conference" withAttributes:@{ + @"jid": roomJid + } andChildren:@[] andData:nil]]; + [directInviteMsg setStoreHint]; + [self->_account send:directInviteMsg]; +} + +-(void) setAffiliation:(NSString*) affiliation ofUser:(NSString*) jid inMuc:(NSString*) roomJid +{ + DDLogInfo(@"Changing affiliation of '%@' in '%@' to '%@'", jid, roomJid, affiliation); + XMPPIQ* updateIq = [[XMPPIQ alloc] initWithType:kiqSetType to:roomJid]; + [updateIq setMucAdminQueryWithAffiliation:affiliation forJid:jid]; + [_account sendIq:updateIq withHandler:$newHandlerWithInvalidation(self, handleAffiliationUpdateResult, handleAffiliationUpdateResultInvalidation, $ID(roomJid), $ID(jid), $ID(affiliation))]; +} + +$$instance_handler(handleAffiliationUpdateResultInvalidation, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, affiliation), $$ID(NSString*, jid), $$ID(NSString*, roomJid)) + DDLogError(@"Failed to change affiliation of '%@' in '%@' to '%@': timeout", jid, roomJid, affiliation); + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to change affiliation of '%@' in '%@' to '%@': timeout", @""), jid, roomJid, affiliation] forMuc:roomJid withNode:nil andIsSevere:YES]; +$$ + +$$instance_handler(handleAffiliationUpdateResult, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, affiliation), $$ID(NSString*, jid), $$ID(NSString*, roomJid)) + if([iqNode check:@"/"]) + { + DDLogError(@"Failed to change affiliation of '%@' in '%@' to '%@': %@", jid, roomJid, affiliation, [iqNode findFirst:@"error"]); + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to change affiliation of '%@' in '%@' to '%@'", @""), jid, roomJid, affiliation] forMuc:roomJid withNode:iqNode andIsSevere:YES]; + return; + } + DDLogInfo(@"Successfully changed affiliation of '%@' in '%@' to '%@'", jid, roomJid, affiliation); + [self callSuccessUIHandlerForMuc:iqNode.fromUser]; +$$ + +-(void) changeNameOfMuc:(NSString*) room to:(NSString*) name +{ + [self incrementNameChange:room]; + [self configureMuc:room withMandatoryOptions:@{ + @"muc#roomconfig_roomname": name, + } andOptionalOptions:@{} deletingMucOnError:NO andJoiningMucOnSuccess:NO]; +} + +-(void) changeSubjectOfMuc:(NSString*) room to:(NSString*) subject +{ + XMPPMessage* msg = [[XMPPMessage alloc] initWithType:kMessageGroupChatType to:room]; + [msg addChildNode:[[MLXMLNode alloc] initWithElement:@"subject" andData:subject]]; + [_account send:msg]; +} + +-(void) publishAvatar:(UIImage* _Nullable) image forMuc:(NSString*) room +{ + if(image == nil) + { + DDLogInfo(@"Removing avatar image for muc '%@'...", room); + XMPPIQ* vcard = [[XMPPIQ alloc] initWithType:kiqSetType to:room]; + [vcard setRemoveVcardAvatar]; + [_account sendIq:vcard withHandler:$newHandlerWithInvalidation(self, handleAvatarPublishResult, handleAvatarPublishResultInvalidation, $ID(room))]; + return; + } + //should work for ejabberd >= 19.02 and prosody >= 0.11 + NSData* imageData = [HelperTools resizeAvatarImage:image withCircularMask:NO toMaxBase64Size:60000]; + NSString* imageHash = [HelperTools hexadecimalString:[HelperTools sha1:imageData]]; + + DDLogInfo(@"Publishing avatar image for muc '%@' with hash %@", room, imageHash); + XMPPIQ* vcard = [[XMPPIQ alloc] initWithType:kiqSetType to:room]; + [vcard setVcardAvatarWithData:imageData andType:@"image/jpeg"]; + [_account sendIq:vcard withHandler:$newHandlerWithInvalidation(self, handleAvatarPublishResult, handleAvatarPublishResultInvalidation, $ID(room))]; +} + +$$instance_handler(handleAvatarPublishResultInvalidation, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, room)) + DDLogError(@"Publishing avatar for muc '%@' returned timeout", room); + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to publish avatar image for group/channel %@", @""), room] forMuc:room withNode:nil andIsSevere:YES]; +$$ + +$$instance_handler(handleAvatarPublishResult, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogError(@"Publishing avatar for muc '%@' returned error: %@", iqNode.fromUser, [iqNode findFirst:@"error"]); + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to publish avatar image for group/channel %@", @""), iqNode.fromUser] forMuc:iqNode.fromUser withNode:iqNode andIsSevere:YES]; + return; + } + DDLogInfo(@"Successfully published avatar for muc: %@", iqNode.fromUser); + [self callSuccessUIHandlerForMuc:iqNode.fromUser]; +$$ + +$$instance_handler(handleDiscoResponseInvalidation, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, roomJid)) + [self decrementNameChange:roomJid]; + DDLogInfo(@"Removing muc '%@' from _joining...", roomJid); + [self removeRoomFromJoining:roomJid]; +$$ + +$$instance_handler(handleDiscoResponse, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, roomJid), $$BOOL(join), $$BOOL(updateBookmarks)) + MLAssert([iqNode.fromUser isEqualToString:roomJid], @"Disco response jid not matching query jid!", (@{ + @"iqNode.fromUser": [NSString stringWithFormat:@"%@", iqNode.fromUser], + @"roomJid": [NSString stringWithFormat:@"%@", roomJid], + })); + + //no matter what the disco response is: we are not creating this muc anymore + //either because we successfully created it and called join afterwards, + //or because the user tried to simultaneously create and join this muc (the join has precendence in this case) + BOOL wasCreating = [self isCreating:roomJid]; + [self removeRoomFromCreating:roomJid]; + + + if([iqNode check:@"//error/{urn:ietf:params:xml:ns:xmpp-stanzas}gone"]) + { + DDLogError(@"Querying muc info returned this muc isn't available anymore: %@", [iqNode findFirst:@"error"]); + [self decrementNameChange:iqNode.fromUser]; + [self removeRoomFromJoining:iqNode.fromUser]; + + //delete muc from favorites table to be sure we don't try to rejoin it and update bookmarks afterwards (to make sure this muc isn't accidentally left in our boomkmarks) + //make sure to update remote bookmarks, even if updateBookmarks == NO + //keep buddy list entry to allow users to read the last messages before the muc got deleted + [self deleteMuc:iqNode.fromUser withBookmarksUpdate:YES keepBuddylistEntry:YES]; + + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Group/Channel not available anymore: %@", @""), iqNode.fromUser] forMuc:iqNode.fromUser withNode:iqNode andIsSevere:YES]; + return; + } + + if([iqNode check:@"//error"]) + { + DDLogError(@"Querying muc info returned a temporary error: %@", [iqNode findFirst:@"error"]); + [self decrementNameChange:iqNode.fromUser]; + [self removeRoomFromJoining:iqNode.fromUser]; + + //do nothing: the error is only temporary (a s2s problem etc.), a muc ping will retry the join + //this will keep the entry in local bookmarks table and remote bookmars + //--> retry the join on mucPing or full login without smacks resume + //this will also keep the buddy list entry + //--> allow users to read the last messages before the muc got broken + + //only display an error banner, no notification (this is only temporary) + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Temporary failure to enter Group/Channel: %@", @""), roomJid] forMuc:roomJid withNode:iqNode andIsSevere:NO]; + return; + } + else if([iqNode check:@"/"]) + { + DDLogError(@"Querying muc info returned a persistent error: %@", [iqNode findFirst:@"error"]); + [self decrementNameChange:iqNode.fromUser]; + [self removeRoomFromJoining:iqNode.fromUser]; + + //delete muc from favorites table to be sure we don't try to rejoin it and update bookmarks afterwards (to make sure this muc isn't accidentally left in our boomkmarks) + //make sure to update remote bookmarks, even if updateBookmarks == NO + //keep buddy list entry to allow users to read the last messages before the muc got deleted/broken + [self deleteMuc:iqNode.fromUser withBookmarksUpdate:YES keepBuddylistEntry:YES]; + + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to enter Group/Channel %@", @""), roomJid] forMuc:roomJid withNode:iqNode andIsSevere:YES]; + return; + } + + //extract features + NSSet* features = [NSSet setWithArray:[iqNode find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; + + //check if this is a muc + if(![features containsObject:@"http://jabber.org/protocol/muc"]) + { + DDLogError(@"muc disco returned that this jid is not a muc!"); + [self decrementNameChange:iqNode.fromUser]; + + //delete muc from favorites table to be sure we don't try to rejoin it and update bookmarks afterwards (to make sure this muc isn't accidentally left in our boomkmarks) + //make sure to update remote bookmarks, even if updateBookmarks == NO + //keep buddy list entry to allow users to read the last messages before the muc got deleted/broken + //AND: to not auto-delete contact list entries via malicious xmpp:?join links + [self deleteMuc:iqNode.fromUser withBookmarksUpdate:YES keepBuddylistEntry:YES]; + + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Failed to enter Group/Channel %@: This is not a Group/Channel!", @""), iqNode.fromUser] forMuc:iqNode.fromUser withNode:nil andIsSevere:YES]; + return; + } + + //force join if this isn't already recorded as muc in our database but as normal user or not recorded at all + if(!join && ![[DataLayer sharedInstance] isBuddyMuc:iqNode.fromUser forAccount:_account.accountID]) + join = YES; + + //the join (join=YES) was aborted by a call to leave (isJoining: returns NO) + if(join && ![self isJoining:iqNode.fromUser]) + { + DDLogWarn(@"Ignoring muc disco result for '%@' on account %@: not joining anymore...", iqNode.fromUser, _account); + [self decrementNameChange:iqNode.fromUser]; + return; + } + + //extract further muc infos + NSString* mucName = [iqNode findFirst:@"{http://jabber.org/protocol/disco#info}query/identity@name"]; + NSString* mucType = kMucTypeChannel; + //both are needed for omemo, see discussion with holger 2021-01-02/03 -- Thilo Molitor + //see also: https://docs.modernxmpp.org/client/groupchat/ + if([features containsObject:@"muc_nonanonymous"] && [features containsObject:@"muc_membersonly"]) + mucType = kMucTypeGroup; + + //update db with new infos + BOOL isBuddyMuc = [[DataLayer sharedInstance] isBuddyMuc:iqNode.fromUser forAccount:_account.accountID]; + if(!isBuddyMuc || wasCreating) + { + if(!isBuddyMuc) + { + //remove old non-muc contact from contactlist (we don't want mucs as normal contacts on our (server) roster and shadowed in monal by the real muc contact) + NSDictionary* existingContactDict = [[DataLayer sharedInstance] contactDictionaryForUsername:iqNode.fromUser forAccount:_account.accountID]; + if(existingContactDict != nil) + { + MLContact* existingContact = [MLContact createContactFromJid:iqNode.fromUser andAccountID:_account.accountID]; + DDLogVerbose(@"Removing already existing contact (%@) having raw db dict: %@", existingContact, existingContactDict); + [_account removeFromRoster:existingContact]; + } + } + //add new muc buddy (potentially deleting a non-muc buddy having the same jid) + NSString* nick = [self calculateNickForMuc:iqNode.fromUser]; + DDLogInfo(@"Adding new muc %@ using nick '%@' to buddylist...", iqNode.fromUser, nick); + [[DataLayer sharedInstance] initMuc:iqNode.fromUser forAccountID:_account.accountID andMucNick:nick]; + //add this room to firstJoin list + @synchronized(_stateLockObject) { + [_firstJoin addObject:iqNode.fromUser]; + if(updateBookmarks == NO) + [_noUpdateBookmarks addObject:iqNode.fromUser]; + } + //make public channels "mention only" on first join + if([kMucTypeChannel isEqualToString:mucType]) + { + DDLogDebug(@"Configuring new muc %@ to be mention-only...", iqNode.fromUser); + [[DataLayer sharedInstance] setMucAlertOnMentionOnly:iqNode.fromUser onAccount:_account.accountID]; + } + } + + if(![mucType isEqualToString:[[DataLayer sharedInstance] getMucTypeOfRoom:iqNode.fromUser andAccount:_account.accountID]]) + { + DDLogInfo(@"Configuring muc %@ to be of type '%@'...", iqNode.fromUser, mucType); + [[DataLayer sharedInstance] updateMucTypeTo:mucType forRoom:iqNode.fromUser andAccount:_account.accountID]; + } + else + DDLogDebug(@"Muc %@ is already configured to be of type '%@' ('%@')...", iqNode.fromUser, mucType, [[DataLayer sharedInstance] getMucTypeOfRoom:iqNode.fromUser andAccount:_account.accountID]); + + if(!mucName || ![mucName length]) + mucName = @""; + //only handle incoming name updates if they are not our own reflected changes + if([self decrementNameChange:iqNode.fromUser]) + { + MLContact* mucContact = [MLContact createContactFromJid:iqNode.fromUser andAccountID:_account.accountID]; + if(![mucName isEqualToString:mucContact.fullName]) + { + DDLogInfo(@"Configuring muc %@ to use name '%@' (old value: '%@')...", iqNode.fromUser, mucName, mucContact.fullName); + [[DataLayer sharedInstance] setFullName:mucName forContact:iqNode.fromUser andAccount:_account.accountID]; + } + } + + DDLogDebug(@"Updating muc contact..."); + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:iqNode.fromUser andAccountID:_account.accountID] + }]; + + @synchronized(_stateLockObject) { + _roomFeatures[iqNode.fromUser] = features; + //we don't need to force saving of our new state because once this incoming iq gets counted by smacks the whole state will be saved + } + + if(join) + { + DDLogInfo(@"Clearing muc participants table: %@", iqNode.fromUser); + [[DataLayer sharedInstance] cleanupParticipantsListFor:iqNode.fromUser onAccountID:_account.accountID]; + + //now try to join this room if requested + [self sendJoinPresenceFor:iqNode.fromUser]; + } +$$ + +-(void) sendJoinPresenceFor:(NSString*) room +{ + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:_account.accountID]; + DDLogInfo(@"Trying to join muc '%@' with nick '%@' on account %@...", room, nick, _account); + @synchronized(_stateLockObject) { + //add room to "currently joining" list (and remove any present idle timer for this room) + [[DataLayer sharedInstance] delIdleTimerWithId:_joining[room]]; + //add idle timer to display error if we did not receive the reflected join presence after 30 idle seconds + //this will make sure the spinner ui will not spin indefinitely when adding a channel via ui + NSNumber* timerId = [[DataLayer sharedInstance] addIdleTimerWithTimeout:@30 andHandler:$newHandler(self, handleJoinTimeout, $ID(room)) onAccountID:_account.accountID]; + _joining[room] = timerId; + //we don't need to force saving of our new state because once this outgoing join presence gets handled by smacks the whole state will be saved + } + + XMPPPresence* presence = [XMPPPresence new]; + [presence joinRoom:room withNick:nick]; + [_account send:presence]; +} + +$$instance_handler(handleJoinTimeout, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, room)) + [self handleError:[NSString stringWithFormat:NSLocalizedString(@"Could not join group/channel '%@': timeout", @""), room] forMuc:room withNode:nil andIsSevere:YES]; + //don't remove the muc, this could be a temporary (network induced) error +$$ + +$$instance_handler(handleMembersList, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, type)) + DDLogInfo(@"Got %@s list from %@...", type, iqNode.fromUser); + DDLogInfo(@"Clearing muc members table for type %@: %@", type, iqNode.fromUser); + [[DataLayer sharedInstance] cleanupMembersListFor:iqNode.fromUser andType:type onAccountID:_account.accountID]; + [self handleMembersListUpdate:[iqNode find:@"{http://jabber.org/protocol/muc#admin}query/item@@"] forMuc:iqNode.fromUser]; + [self logMembersOfMuc:iqNode.fromUser]; +$$ + +$$instance_handler(handleMamResponseWithLatestId, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + if([iqNode check:@"/"]) + { + DDLogWarn(@"Muc mam latest stanzaid query %@ returned error: %@", iqNode.id, [iqNode findFirst:@"error"]); + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"Failed to query new messages for Group/Channel (stanzaid) %@", @""), iqNode.fromUser] withNode:iqNode andAccount:_account andIsSevere:YES]; + [_account mamFinishedFor:iqNode.fromUser]; + return; + } + DDLogVerbose(@"Got latest muc stanza id to prime database with: %@", [iqNode findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]); + //only do this if we got a valid stanza id (not null) + //if we did not get one we will get one when receiving the next muc message in this smacks session + //if the smacks session times out before we get a message and someone sends us one or more messages before we had a chance to establish + //a new smacks session, this messages will get lost because we don't know how to query the archive for this message yet + //once we successfully receive the first mam-archived message stanza (could even be an XEP-184 ack for a sent message), + //no more messages will get lost + //we ignore this single message loss here, because it should be super rare and solving it would be really complicated + if([iqNode check:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]) + [[DataLayer sharedInstance] setLastStanzaId:[iqNode findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"] forMuc:iqNode.fromUser andAccount:_account.accountID]; + [_account mamFinishedFor:iqNode.fromUser]; +$$ + +$$instance_handler(handleCatchup, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$BOOL(secondTry)) + if([iqNode check:@"/"]) + { + DDLogWarn(@"Muc mam catchup query %@ returned error: %@", iqNode.id, [iqNode findFirst:@"error"]); + + //handle weird XEP-0313 monkey-patching XEP-0059 behaviour (WHY THE HELL??) + if(!secondTry && [iqNode check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}item-not-found"]) + { + //latestMessage can be nil, thus [latestMessage timestamp] will return nil and setMAMQueryAfterTimestamp:nil + //will query the whole archive since dawn of time + MLMessage* latestMessage = [[DataLayer sharedInstance] lastMessageForContact:iqNode.fromUser forAccount:_account.accountID]; + DDLogInfo(@"Querying COMPLETE muc mam:2 archive at %@ after timestamp %@ for catchup", iqNode.fromUser, [latestMessage timestamp]); + XMPPIQ* mamQuery = [[XMPPIQ alloc] initWithType:kiqSetType to:iqNode.fromUser]; + [mamQuery setMAMQueryAfterTimestamp:[latestMessage timestamp]]; + [_account sendIq:mamQuery withHandler:$newHandler(self, handleCatchup, $BOOL(secondTry, YES))]; + } + else + { + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"Failed to query new messages for Group/Channel (catchup) %@", @""), iqNode.fromUser] withNode:iqNode andAccount:_account andIsSevere:YES]; + [_account mamFinishedFor:iqNode.fromUser]; + } + return; + } + if(![[iqNode findFirst:@"{urn:xmpp:mam:2}fin@complete|bool"] boolValue] && [iqNode check:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]) + { + DDLogVerbose(@"Paging through muc mam catchup results at %@ with after: %@", iqNode.fromUser, [iqNode findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]); + //do RSM forward paging + XMPPIQ* pageQuery = [[XMPPIQ alloc] initWithType:kiqSetType to:iqNode.fromUser]; + [pageQuery setMAMQueryAfter:[iqNode findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/last#"]]; + [_account sendIq:pageQuery withHandler:$newHandler(self, handleCatchup, $BOOL(secondTry, NO))]; + } + else if([[iqNode findFirst:@"{urn:xmpp:mam:2}fin@complete|bool"] boolValue]) + { + DDLogVerbose(@"Muc mam catchup of %@ finished", iqNode.fromUser); + [_account mamFinishedFor:iqNode.fromUser]; + } +$$ + +-(void) fetchAvatarForRoom:(NSString*) room +{ + XMPPIQ* vcardQuery = [[XMPPIQ alloc] initWithType:kiqGetType to:room]; + [vcardQuery setVcardQuery]; + [_account sendIq:vcardQuery withHandler:$newHandler(self, handleVcardResponse)]; +} + +$$instance_handler(handleVcardResponse, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) + BOOL deleteAvatar = ![iqNode check:@"{vcard-temp}vCard/PHOTO/BINVAL"]; + + if([iqNode check:@"/"]) + { + DDLogError(@"Failed to retrieve avatar of muc '%@', error: %@", iqNode.fromUser, [iqNode findFirst:@"error"]); + deleteAvatar = YES; + } + + if(deleteAvatar) + { + [[MLImageManager sharedInstance] setIconForContact:[MLContact createContactFromJid:iqNode.fromUser andAccountID:_account.accountID] WithData:nil]; + [[DataLayer sharedInstance] setAvatarHash:@"" forContact:iqNode.fromUser andAccount:_account.accountID]; + //delete cache to make sure the image will be regenerated + [[MLImageManager sharedInstance] purgeCacheForContact:iqNode.fromUser andAccount:_account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:iqNode.fromUser andAccountID:_account.accountID] + }]; + DDLogInfo(@"Avatar of muc '%@' deleted successfully", iqNode.fromUser); + } + else + { + //this should be small enough to not crash the appex when loading the image from file later on but large enough to have excellent quality + NSData* imageData = [iqNode findFirst:@"{vcard-temp}vCard/PHOTO/BINVAL#|base64"]; + if([HelperTools isAppExtension] && imageData.length > 128 * 1024) + { + DDLogWarn(@"Not processing avatar image data of muc '%@' because it is too big to be handled in appex (%lu bytes), rescheduling it to be fetched in mainapp", iqNode.fromUser, (unsigned long)imageData.length); + [_account addReconnectionHandler:$newHandler(self, fetchAvatarAgain, $ID(jid, iqNode.fromUser))]; + return; + } + + //this will consume a large portion of ram because it will be represented as uncomressed bitmap + UIImage* image = [UIImage imageWithData:imageData]; + NSString* avatarHash = [HelperTools hexadecimalString:[HelperTools sha1:imageData]]; + //this upper limit is roughly 1.4MiB memory (600x600 with 4 byte per pixel) + if(![HelperTools isAppExtension] || image.size.width * image.size.height < 600 * 600) + { + NSData* imageData = [HelperTools resizeAvatarImage:image withCircularMask:YES toMaxBase64Size:256000]; + [[MLImageManager sharedInstance] setIconForContact:[MLContact createContactFromJid:iqNode.fromUser andAccountID:_account.accountID] WithData:imageData]; + [[DataLayer sharedInstance] setAvatarHash:avatarHash forContact:iqNode.fromUser andAccount:_account.accountID]; + //delete cache to make sure the image will be regenerated + [[MLImageManager sharedInstance] purgeCacheForContact:iqNode.fromUser andAccount:_account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": [MLContact createContactFromJid:iqNode.fromUser andAccountID:_account.accountID] + }]; + DDLogInfo(@"Avatar of muc '%@' fetched and updated successfully", iqNode.fromUser); + } + else + { + DDLogWarn(@"Not loading avatar image of muc '%@' because it is too big to be processed in appex (%lux%lu pixels), rescheduling it to be fetched in mainapp", iqNode.fromUser, (unsigned long)image.size.width, (unsigned long)image.size.height); + [_account addReconnectionHandler:$newHandler(self, fetchAvatarAgain, $ID(jid, iqNode.fromUser))]; + } + } +$$ + +//this handler will simply retry the vcard fetch attempt if in mainapp +$$instance_handler(fetchAvatarAgain, account.mucProcessor, $$ID(xmpp*, account), $$ID(NSString*, jid)) + if([HelperTools isAppExtension]) + { + DDLogWarn(@"Not loading avatar image of '%@' because we are still in appex, rescheduling it again!", jid); + [_account addReconnectionHandler:$newHandler(self, fetchAvatarAgain, $ID(jid))]; + } + else + [self fetchAvatarForRoom:jid]; +$$ + +-(void) handleError:(NSString*) description forMuc:(NSString*) room withNode:(XMPPStanza*) node andIsSevere:(BOOL) isSevere +{ + monal_id_block_t uiHandler = [self getUIHandlerForMuc:room]; + //call ui handler if registered for this room + if(uiHandler) + { + //remove handler (it will only be called once) + [self removeUIHandlerForMuc:room]; + + //prepare data + NSString* message = description; + if(node != nil) + message = [HelperTools extractXMPPError:node withDescription:description]; + NSDictionary* data = @{ + @"success": @NO, + @"muc": room, + @"account": _account, + @"errorMessage": message + }; + + DDLogInfo(@"Calling UI error handler with %@", data); + dispatch_async(dispatch_get_main_queue(), ^{ + uiHandler(data); + }); + } + //otherwise call the general error handler + else + [HelperTools postError:description withNode:node andAccount:_account andIsSevere:isSevere]; +} + +-(void) callSuccessUIHandlerForMuc:(NSString*) room withCallback:(monal_void_block_t) callback +{ + monal_id_block_t uiHandler = [self getUIHandlerForMuc:room]; + if(uiHandler) + { + //remove handler (it will only be called once) + [self removeUIHandlerForMuc:room]; + + DDLogInfo(@"Calling UI handler for muc %@...", room); + dispatch_async(dispatch_get_main_queue(), ^{ + if(callback != nil) + uiHandler(@{ + @"success": @YES, + @"muc": room, + @"account": self->_account, + @"callback": callback, + }); + else + uiHandler(@{ + @"success": @YES, + @"muc": room, + @"account": self->_account, + }); + }); + } +} + +-(void) callSuccessUIHandlerForMuc:(NSString*) room +{ + return [self callSuccessUIHandlerForMuc:room withCallback:nil]; +} + +-(void) updateBookmarks +{ + DDLogVerbose(@"Updating bookmarks on account %@", _account); + //use bookmarks2, if server supports syncing between XEP-0048 and XEP-0402 bookmarks + //use old-style XEP-0048 bookmarks, if not + if(_account.connectionProperties.supportsBookmarksCompat) + [_account.pubsub fetchNode:@"urn:xmpp:bookmarks:1" from:_account.connectionProperties.identity.jid withItemsList:nil andHandler:$newHandler(MLPubSubProcessor, handleBookmarks2FetchResult)]; + else + [_account.pubsub fetchNode:@"storage:bookmarks" from:_account.connectionProperties.identity.jid withItemsList:nil andHandler:$newHandler(MLPubSubProcessor, handleBookarksFetchResult)]; +} + +-(BOOL) checkIfStillBookmarked:(NSString*) room +{ + room = [room lowercaseString]; + for(NSString* entry in [[DataLayer sharedInstance] listMucsForAccount:_account.accountID]) + if([room isEqualToString:entry]) + return YES; + return NO; +} + +-(NSSet*) getRoomFeaturesForMuc:(NSString*) room +{ + return _roomFeatures[room]; +} + +-(void) deleteMuc:(NSString*) room withBookmarksUpdate:(BOOL) updateBookmarks keepBuddylistEntry:(BOOL) keepBuddylistEntry +{ + DDLogInfo(@"Deleting muc %@ on account %@...", room, _account); + + //delete muc from favorites table and update bookmarks if requested + [[DataLayer sharedInstance] deleteMuc:room forAccountID:_account.accountID]; + if(updateBookmarks) + [self updateBookmarks]; + + //update buddylist (e.g. contact list) if requested + MLContact* contact = [MLContact createContactFromJid:room andAccountID:_account.accountID]; + [contact removeShareInteractions]; + if(keepBuddylistEntry) + { + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:_account userInfo:@{ + @"contact": contact + }]; + } + else + { + [[DataLayer sharedInstance] removeBuddy:room forAccount:_account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRemoved object:_account userInfo:@{ + @"contact": contact + }]; + } +} + +-(NSString*) calculateNickForMuc:(NSString*) room +{ + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:_account.accountID]; + //use the account display name as nick, if nothing can be found in buddylist and muc_favorites db tables + if(!nick) + { + nick = [MLContact ownDisplayNameForAccount:_account]; + DDLogInfo(@"Using default nick '%@' for room %@ on account %@", nick, room, _account); + } + return nick; +} + +-(void) removeRoomFromCreating:(NSString*) room +{ + @synchronized(_stateLockObject) { + DDLogVerbose(@"Removing from _creating[%@]: %@", room, _creating[room]); + [[DataLayer sharedInstance] delIdleTimerWithId:_creating[room]]; + [_creating removeObjectForKey:room]; + } +} + +-(void) removeRoomFromJoining:(NSString*) room +{ + @synchronized(_stateLockObject) { + DDLogVerbose(@"Removing from _joining[%@]: %@", room, _joining[room]); + [[DataLayer sharedInstance] delIdleTimerWithId:_joining[room]]; + [_joining removeObjectForKey:room]; + } +} + +-(void) logMembersOfMuc:(NSString*) jid +{ + if([[[DataLayer sharedInstance] getMucTypeOfRoom:jid andAccount:_account.accountID] isEqualToString:kMucTypeGroup]) + DDLogInfo(@"Currently recorded members and participants of group %@: %@", jid, [[DataLayer sharedInstance] getMembersAndParticipantsOfMuc:jid forAccountID:_account.accountID]); + else + { +//these lists can potentially get really long for public channels --> restrict logging them to alpha builds +#ifdef IS_ALPHA + DDLogInfo(@"Currently recorded members and participants of channel %@: %@", jid, [[DataLayer sharedInstance] getMembersAndParticipantsOfMuc:jid forAccountID:_account.accountID]); +#endif + } +} + +-(NSString*) generateSpeakableGroupNode +{ + NSArray* charLists = @[ + @"bcdfghjklmnpqrstvwxyz", + @"aeiou", + ]; + NSMutableString* retval = [NSMutableString new]; + int charTypeBegin = arc4random() % charLists.count; + for(int i=0; i<10; i++) + { + NSString* selectedCharList = charLists[(i + charTypeBegin) % charLists.count]; + [retval appendString:[selectedCharList substringWithRange:NSMakeRange(arc4random() % selectedCharList.length, 1)]]; + } + return retval; +} + +@end diff --git a/Monal/Classes/MLNotificationManager.h b/Monal/Classes/MLNotificationManager.h new file mode 100644 index 0000000..87f4035 --- /dev/null +++ b/Monal/Classes/MLNotificationManager.h @@ -0,0 +1,24 @@ +// +// MLNotificationManager.h +// Monal +// +// Created by Anurodh Pokharel on 7/20/13. +// +// + +#import +#import +#import "MLConstants.h" +#import "DataLayer.h" + +/** + Singleton object that will handle all sliders, alerts and sounds. listens for new message notification. + */ +@interface MLNotificationManager : NSObject + ++(MLNotificationManager*) sharedInstance; + +@property (nonatomic, strong) MLContact* currentContact; +-(void) donateInteractionForOutgoingDBId:(NSNumber*) messageDBId; + +@end diff --git a/Monal/Classes/MLNotificationManager.m b/Monal/Classes/MLNotificationManager.m new file mode 100644 index 0000000..1c84d13 --- /dev/null +++ b/Monal/Classes/MLNotificationManager.m @@ -0,0 +1,916 @@ +// +// MLNotificationManager.m +// Monal +// +// Created by Anurodh Pokharel on 7/20/13. +// +// + +#import "HelperTools.h" +#import "MLNotificationManager.h" +#import "MLImageManager.h" +#import "MLMessage.h" +#import "MLXEPSlashMeHandler.h" +#import "MLConstants.h" +#import "xmpp.h" +#import "MLFiletransfer.h" +#import "MLNotificationQueue.h" +#import "MLXMPPManager.h" +#import + +@import UserNotifications; +@import CoreServices; +@import Intents; +@import AVFoundation; +@import UniformTypeIdentifiers; + +typedef NS_ENUM(NSUInteger, MLNotificationState) { + MLNotificationStateNone, + MLNotificationStatePending, + MLNotificationStateDelivered, +}; + +@interface MLNotificationManager () +@property (nonatomic, readonly) NotificationPrivacySettingOption notificationPrivacySetting; +@end + +@implementation MLNotificationManager + ++(MLNotificationManager*) sharedInstance +{ + static dispatch_once_t once; + static MLNotificationManager* sharedInstance; + dispatch_once(&once, ^{ + sharedInstance = [MLNotificationManager new] ; + }); + return sharedInstance; +} + +-(id) init +{ + self = [super init]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNewMessage:) name:kMonalNewMessageNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleFiletransferUpdate:) name:kMonalMessageFiletransferUpdateNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleDeletedMessage:) name:kMonalDeletedMessageNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleDisplayedMessages:) name:kMonalDisplayedMessagesNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleXMPPError:) name:kXMPPError object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleContactRefresh:) name:kMonalContactRefresh object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleContactRefresh:) name:kMonalContactRemoved object:nil]; + return self; +} + +-(NotificationPrivacySettingOption) notificationPrivacySetting +{ + NotificationPrivacySettingOption value = (NotificationPrivacySettingOption)[[HelperTools defaultsDB] integerForKey:@"NotificationPrivacySetting"]; + DDLogVerbose(@"Current NotificationPrivacySettingOption: %d", (int)value); + return value; +} + +-(void) handleContactRefresh:(NSNotification*) notification +{ + //these will not survive process switches, but that's enough for now + static NSMutableSet* displayed; + static NSMutableSet* removed; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + displayed = [NSMutableSet new]; + removed = [NSMutableSet new]; + }); + xmpp* xmppAccount = notification.object; + MLContact* contact = notification.userInfo[@"contact"]; + NSString* idval = [NSString stringWithFormat:@"subscription(%@, %@)", contact.accountID, contact.contactJid]; + + //remove contact requests notification once the contact request has been accepted + if(!contact.hasIncomingContactRequest) + { + monal_void_block_t block = ^{ + [[UNUserNotificationCenter currentNotificationCenter] getPendingNotificationRequestsWithCompletionHandler:^(NSArray* requests) { + for(UNNotificationRequest* request in requests) + if([request.identifier isEqualToString:idval]) + { + DDLogVerbose(@"Removing pending handled subscription request notification with identifier '%@'...", idval); + [[UNUserNotificationCenter currentNotificationCenter] removePendingNotificationRequestsWithIdentifiers:@[idval]]; + } + }]; + [[UNUserNotificationCenter currentNotificationCenter] getDeliveredNotificationsWithCompletionHandler:^(NSArray* notifications) { + for(UNNotification* notification in notifications) + if([notification.request.identifier isEqualToString:idval]) + { + DDLogVerbose(@"Removing delivered handled subscription request notification with identifier '%@'...", idval); + [[UNUserNotificationCenter currentNotificationCenter] removeDeliveredNotificationsWithIdentifiers:@[idval]]; + } + }]; + @synchronized(removed) { + [removed addObject:idval]; + } + }; + + //only try to remove once + BOOL isContained = NO; + @synchronized(removed) { + isContained = [removed containsObject:idval]; + } + if(!isContained) + { + //do this in its own thread because we don't want to block the main thread or other threads here (the removal can take ~50ms) + //but DON'T do this in the appex because this can try to mess with notifications after the parse queue was frozen (see appex code for explanation what this means) + if([HelperTools isAppExtension]) + block(); + else + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), block); + } + + //return because we don't want to display any contact request notification + return; + } + + //don't alert twice + @synchronized(displayed) { + if([displayed containsObject:idval]) + return; + } + + UNMutableNotificationContent* content = [UNMutableNotificationContent new]; + content.title = xmppAccount.connectionProperties.identity.jid; + content.body = [NSString stringWithFormat:NSLocalizedString(@"The user %@ (%@) wants to add you to their contact list", @""), contact.contactDisplayName, contact.contactJid]; + content.threadIdentifier = [self threadIdentifierWithContact:contact]; + content.categoryIdentifier = @"subscription"; + //don't simply use contact directly to make sure we always use a freshly created up to date contact when unpacking the userInfo dict + content.userInfo = @{ + @"fromContactJid": contact.contactJid, + @"fromContactAccountID": contact.accountID, + }; + + DDLogDebug(@"Publishing notification with id %@", idval); + [self publishNotificationContent:content withID:idval]; + @synchronized(displayed) { + [displayed addObject:idval]; + } +} + +-(void) handleXMPPError:(NSNotification*) notification +{ + //severe errors will be shown as notification (in addition to the banner shown if the app is in foreground) + if([notification.userInfo[@"isSevere"] boolValue]) + { + xmpp* xmppAccount = notification.object; + DDLogError(@"SEVERE XMPP Error(%@): %@", xmppAccount.connectionProperties.identity.jid, notification.userInfo[@"message"]); +#ifdef IS_ALPHA + NSString* idval = [[NSUUID UUID] UUIDString]; +#else + NSString* idval = xmppAccount.connectionProperties.identity.jid; //use this to only show the newest error notification per account +#endif + UNMutableNotificationContent* content = [UNMutableNotificationContent new]; + content.title = xmppAccount.connectionProperties.identity.jid; + content.body = notification.userInfo[@"message"]; + content.sound = [UNNotificationSound defaultSound]; + UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:idval content:content trigger:nil]; + NSError* error = [HelperTools postUserNotificationRequest:request]; + if(error) + DDLogError(@"Error posting xmppError notification: %@", error); + } +} + +#pragma mark message signals + +-(AnyPromise*) notificationStateForMessage:(MLMessage*) message +{ + NSString* idval = [self identifierWithMessage:message]; + NSMutableArray* promises = [NSMutableArray new]; + + [promises addObject:[AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + DDLogVerbose(@"Checking for 'pending' notification state for '%@'...", idval); + [[UNUserNotificationCenter currentNotificationCenter] getPendingNotificationRequestsWithCompletionHandler:^(NSArray* requests) { + for(UNNotificationRequest* request in requests) + if([request.identifier isEqualToString:idval]) + { + DDLogDebug(@"Notification state 'pending' for: %@", idval); + resolve(@(MLNotificationStatePending)); + return; + } + resolve(@(MLNotificationStateNone)); + }]; + }]]; + + [promises addObject:[AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + DDLogVerbose(@"Checking for 'delivered' notification state for '%@'...", idval); + [[UNUserNotificationCenter currentNotificationCenter] getDeliveredNotificationsWithCompletionHandler:^(NSArray* notifications) { + for(UNNotification* notification in notifications) + if([notification.request.identifier isEqualToString:idval]) + { + DDLogDebug(@"Notification state 'delivered' for: %@", idval); + resolve(@(MLNotificationStateDelivered)); + return; + } + resolve(@(MLNotificationStateNone)); + }]; + }]]; + + + return PMKWhen(promises).then(^(NSArray* results) { + DDLogVerbose(@"Notification state check for '%@' completed...", idval); + for(NSNumber* entry in results) + if(entry.integerValue != MLNotificationStateNone) + return entry; + return @(MLNotificationStateNone); + }); +} + +-(void) handleFiletransferUpdate:(NSNotification*) notification +{ + xmpp* xmppAccount = notification.object; + MLMessage* message = [notification.userInfo objectForKey:@"message"]; + NSString* idval = [self identifierWithMessage:message]; + //do this asynchronously on a background thread + [self notificationStateForMessage:message].thenInBackground(^(NSNumber* _state) { + MLNotificationState state = _state.integerValue; + if(state == MLNotificationStatePending) + { + DDLogDebug(@"Already pending or unknown notification '%@', updating/posting it...", idval); + [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:YES andLMCReplaced:NO]; + } + else if(state == MLNotificationStateDelivered) + { + DDLogDebug(@"Already displayed notification '%@', updating it...", idval); + [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:NO andLMCReplaced:NO]; + } + }); +} + +-(void) handleNewMessage:(NSNotification*) notification +{ + xmpp* xmppAccount = notification.object; + MLMessage* message = [notification.userInfo objectForKey:@"message"]; + BOOL showAlert = notification.userInfo[@"showAlert"] ? [notification.userInfo[@"showAlert"] boolValue] : NO; + BOOL LMCReplaced = notification.userInfo[@"LMCReplaced"] ? [notification.userInfo[@"LMCReplaced"] boolValue] : NO; + [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:showAlert andSound:YES andLMCReplaced:LMCReplaced]; +} + +-(void) internalMessageHandlerWithMessage:(MLMessage*) message andAccount:(xmpp*) xmppAccount showAlert:(BOOL) showAlert andSound:(BOOL) sound andLMCReplaced:(BOOL) LMCReplaced +{ + if([message.messageType isEqualToString:kMessageTypeStatus]) + return; + DDLogVerbose(@"notification manager should show notification for: %@", message.messageText); + if(!showAlert) + { + DDLogDebug(@"not showing notification: showAlert is NO"); + return; + } + + BOOL muted = [[DataLayer sharedInstance] isMutedJid:message.buddyName onAccount:message.accountID]; + if(!muted && message.isMuc == YES && [[DataLayer sharedInstance] isMucAlertOnMentionOnly:message.buddyName onAccount:message.accountID]) + { + NSString* displayName = [MLContact ownDisplayNameForAccount:xmppAccount]; + NSString* ownJid = xmppAccount.connectionProperties.identity.jid; + NSString* userPart = [HelperTools splitJid:ownJid][@"user"]; + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:message.buddyName forAccount:message.accountID]; + if(!( + [message.messageText localizedCaseInsensitiveContainsString:nick] || + [message.messageText localizedCaseInsensitiveContainsString:displayName] || + [message.messageText localizedCaseInsensitiveContainsString:userPart] || + [message.messageText localizedCaseInsensitiveContainsString:ownJid] + )) + muted = YES; + } + if(muted) + { + DDLogDebug(@"not showing notification: this contact got muted"); + return; + } + + //check if we need to replace the still displayed notification or ignore this LMC + if(LMCReplaced) + { + NSString* idval = [self identifierWithMessage:message]; + //wait synchronous for completion (needed for appex) + MLNotificationState state = PMKHangEnum([self notificationStateForMessage:message]); + DDLogVerbose(@"Notification state for '%@': %@", idval, @(state)); + if(state == MLNotificationStateNone) + { + DDLogDebug(@"not showing notification for LMC: this notification was already removed earlier"); + return; + } + } + + if([HelperTools isNotInFocus]) + { + DDLogVerbose(@"notification manager should show notification in background: %@", message.messageText); + [self showNotificationForMessage:message withSound:sound andAccount:xmppAccount]; + } + else + { + //don't show notifications for open chats + if(![message isEqualToContact:self.currentContact]) + { + DDLogVerbose(@"notification manager should show notification in foreground: %@", message.messageText); + [self showNotificationForMessage:message withSound:sound andAccount:xmppAccount]; + } + else + { + DDLogDebug(@"not showing notification and only playing sound: chat is open"); + [self playNotificationSoundForMessage:message withSound:sound andAccount:xmppAccount]; + } + } +} + +-(void) handleDisplayedMessages:(NSNotification*) notification +{ + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + NSArray* messages = [notification.userInfo objectForKey:@"messagesArray"]; + DDLogVerbose(@"notification manager got displayed messages notice with %lu entries", [messages count]); + + monal_void_block_t block = ^{ + for(MLMessage* msg in messages) + { + if([msg.messageType isEqualToString:kMessageTypeStatus]) + return; + + NSString* idval = [self identifierWithMessage:msg]; + + DDLogVerbose(@"Removing pending/delivered notification for message '%@' with identifier '%@'...", msg.messageId, idval); + [center removePendingNotificationRequestsWithIdentifiers:@[idval]]; + [center removeDeliveredNotificationsWithIdentifiers:@[idval]]; + } + }; + + //do this in its own thread because we don't want to block the main thread or other threads here (the removal can take ~50ms) + //but DON'T do this in the appex because this can try to mess with notifications after the parse queue was frozen (see appex code for explanation what this means) + if([HelperTools isAppExtension]) + block(); + else + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), block); + + //update app badge + [[MLNotificationQueue currentQueue] postNotificationName:kMonalUpdateUnread object:nil]; + +} + +-(void) handleDeletedMessage:(NSNotification*) notification +{ + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + MLMessage* message = [notification.userInfo objectForKey:@"message"]; + + if([message.messageType isEqualToString:kMessageTypeStatus]) + return; + + NSString* idval = [self identifierWithMessage:message]; + + DDLogVerbose(@"notification manager got deleted message notice: %@", message.messageId); + [center removePendingNotificationRequestsWithIdentifiers:@[idval]]; + [center removeDeliveredNotificationsWithIdentifiers:@[idval]]; + + //update app badge + [[MLNotificationQueue currentQueue] postNotificationName:kMonalUpdateUnread object:nil]; +} + +-(NSString*) identifierWithMessage:(MLMessage*) message +{ + return [NSString stringWithFormat:@"message(%@, %@)", [self threadIdentifierWithMessage:message], message.messageId]; +} + +-(NSString*) threadIdentifierWithMessage:(MLMessage*) message +{ + return [NSString stringWithFormat:@"thread(%@, %@)", message.accountID, message.buddyName]; +} + +-(NSString*) threadIdentifierWithContact:(MLContact*) contact +{ + return [NSString stringWithFormat:@"thread(%@, %@)", contact.accountID, contact.contactJid]; +} + +-(UNMutableNotificationContent*) updateBadgeForContent:(UNMutableNotificationContent*) content +{ + NSNumber* unreadMsgCnt = [[DataLayer sharedInstance] countUnreadMessages]; + DDLogVerbose(@"Raw badge value: %@", unreadMsgCnt); + content.badge = unreadMsgCnt; + return content; +} + +-(void) publishNotificationContent:(UNNotificationContent*) content withID:(NSString*) idval +{ + //scheduling the notification in 2 seconds will make it possible to be deleted by XEP-0333 chat-markers received directly after the message + //this is useful in catchup scenarios + DDLogVerbose(@"notification manager: publishing notification in 2 seconds: %@", content); + UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:idval content:content trigger:[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:2 repeats: NO]]; + NSError* error = [HelperTools postUserNotificationRequest:request]; + if(error) + DDLogError(@"Error posting local notification: %@", error); +} + +-(void) playNotificationSoundForMessage:(MLMessage*) message withSound:(BOOL) sound andAccount:(xmpp*) account +{ + UNMutableNotificationContent* content = [UNMutableNotificationContent new]; + NSString* idval = [self identifierWithMessage:message]; + + if(sound && [[HelperTools defaultsDB] boolForKey:@"Sound"]) + { + NSString* filename = [[HelperTools defaultsDB] objectForKey:@"AlertSoundFile"]; + if(filename) + { + content.sound = [UNNotificationSound soundNamed:[NSString stringWithFormat:@"AlertSounds/%@.aif", filename]]; + DDLogDebug(@"Using user configured alert sound: %@", content.sound); + } + else + { + content.sound = [UNNotificationSound defaultSound]; + DDLogDebug(@"Using default alert sound: %@", content.sound); + } + } + else + DDLogDebug(@"Using no alert sound"); + + DDLogDebug(@"Publishing sound-but-no-body notification with id %@", idval); + [self publishNotificationContent:[self updateBadgeForContent:content] withID:idval]; +} + +-(void) showNotificationForMessage:(MLMessage*) message withSound:(BOOL) sound andAccount:(xmpp*) account +{ + // always use legacy notifications if we should only show a generic "New Message" notifiation without name or content + if(self.notificationPrivacySetting > NotificationPrivacySettingOptionDisplayOnlyName) + return [self showLegacyNotificationForMessage:message withSound:sound]; + + return [self showModernNotificationForMessage:message withSound:sound andAccount:account]; +} + +-(void) showModernNotificationForMessage:(MLMessage*) message withSound:(BOOL) sound andAccount:(xmpp*) account +{ + UNMutableNotificationContent* content = [UNMutableNotificationContent new]; + NSString* idval = [self identifierWithMessage:message]; + + INSendMessageAttachment* audioAttachment = nil; + NSString* msgText = NSLocalizedString(@"Open app to see more", @""); + + //only show msgText if allowed + if(self.notificationPrivacySetting == NotificationPrivacySettingOptionDisplayNameAndMessage) + { + //XEP-0245: The slash me Command + if([message.messageText hasPrefix:@"/me "]) + msgText = [[MLXEPSlashMeHandler sharedInstance] stringSlashMeWithMessage:message]; + else + msgText = message.messageText; + + //notification settings + content.threadIdentifier = [self threadIdentifierWithMessage:message]; + content.categoryIdentifier = @"message"; + + //user info for answer etc. + //don't simply use contact directly to make sure we always use a freshly created up to date contact when unpacking the userInfo dict + content.userInfo = @{ + @"fromContactJid": message.buddyName, + @"fromContactAccountID": message.accountID, + @"messageId": message.messageId + }; + + if([message.messageType isEqualToString:kMessageTypeFiletransfer]) + { + NSDictionary* info = [MLFiletransfer getFileInfoForMessage:message]; + if(info) + { + NSString* mimeType = info[@"mimeType"]; + + if([mimeType hasPrefix:@"image/"]) + msgText = NSLocalizedString(@"📷 An Image", @""); + else if([mimeType hasPrefix:@"audio/"]) + msgText = NSLocalizedString(@"🎵 An Audiomessage", @""); + else if([mimeType hasPrefix:@"video/"]) + msgText = NSLocalizedString(@"🎥 A Video", @""); + else if([mimeType isEqualToString:@"application/pdf"]) + msgText = NSLocalizedString(@"📄 A Document", @""); + else + msgText = NSLocalizedString(@"📁 A File", @""); + + if(![info[@"needsDownloading"] boolValue]) + { + if([mimeType hasPrefix:@"image/"]) + { + UNNotificationAttachment* attachment; + UTType* typeHint = [UTType typeWithMIMEType:mimeType]; + if(typeHint == nil) + typeHint = UTTypeImage; + attachment = [self createNotificationAttachmentForFileInfo:info havingTypeHint:typeHint]; + if(attachment) + content.attachments = @[attachment]; + } + else if([mimeType hasPrefix:@"audio/"]) + { + UNNotificationAttachment* attachment; + UTType* typeHint = [UTType typeWithMIMEType:mimeType]; + if(typeHint == nil) + typeHint = UTTypeAudio; + audioAttachment = [INSendMessageAttachment attachmentWithAudioMessageFile:[INFile fileWithFileURL:[NSURL fileURLWithPath:info[@"cacheFile"]] filename:info[@"filename"] typeIdentifier:typeHint.identifier]]; + DDLogVerbose(@"Added audio attachment(%@ = %@): %@", mimeType, typeHint, audioAttachment); + attachment = [self createNotificationAttachmentForFileInfo:info havingTypeHint:typeHint]; + if(attachment) + content.attachments = @[attachment]; + } + else if([mimeType hasPrefix:@"video/"]) + { + UNNotificationAttachment* attachment; + UTType* typeHint = [UTType typeWithMIMEType:mimeType]; + if(typeHint == nil) + typeHint = UTTypeMovie; + attachment = [self createNotificationAttachmentForFileInfo:info havingTypeHint:typeHint]; + if(attachment) + content.attachments = @[attachment]; + } + } + } + else + { + // empty info dict default to "Sent a file" + DDLogWarn(@"Got filetransfer with unknown type"); + msgText = NSLocalizedString(@"📁 A File", @""); + } + } + else if([message.messageType isEqualToString:kMessageTypeUrl] && [[HelperTools defaultsDB] boolForKey:@"ShowURLPreview"]) + msgText = NSLocalizedString(@"🔗 A Link", @""); + else if([message.messageType isEqualToString:kMessageTypeGeo]) + msgText = NSLocalizedString(@"📍 A Location", @""); + } + content.body = msgText; //save message text to notification content + + if(sound && [[HelperTools defaultsDB] boolForKey:@"Sound"]) + { + NSString* filename = [[HelperTools defaultsDB] objectForKey:@"AlertSoundFile"]; + if(filename) + { + content.sound = [UNNotificationSound soundNamed:[NSString stringWithFormat:@"AlertSounds/%@.aif", filename]]; + DDLogDebug(@"Using user configured alert sound: %@", content.sound); + } + else + { + content.sound = [UNNotificationSound defaultSound]; + DDLogDebug(@"Using default alert sound: %@", content.sound); + } + } + else + DDLogDebug(@"Using no alert sound"); + + // update badge value prior to donating the interaction to sirikit + [self updateBadgeForContent:content]; + + INSendMessageIntent* intent = [self makeIntentForMessage:message usingText:msgText andAudioAttachment:audioAttachment direction:INInteractionDirectionIncoming]; + + INInteraction* interaction = [[INInteraction alloc] initWithIntent:intent response:nil]; + interaction.direction = INInteractionDirectionIncoming; + + NSError* error = nil; + UNNotificationContent* updatedContent = [content contentByUpdatingWithProvider:intent error:&error]; + if(error) + DDLogError(@"Could not update notification content: %@", error); + else + { + DDLogDebug(@"Publishing communication notification with id %@", idval); + [self publishNotificationContent:updatedContent withID:idval]; + } + + //we can donate interactions after posting their notification (see signal source code) + [interaction donateInteractionWithCompletion:^(NSError *error) { + if(error) + DDLogError(@"Could not donate interaction: %@", error); + }]; +} + +-(void) donateInteractionForOutgoingDBId:(NSNumber*) messageDBId +{ + MLMessage* message = [[DataLayer sharedInstance] messageForHistoryID:messageDBId]; + INSendMessageIntent* intent = [self makeIntentForMessage:message usingText:@"dummyText" andAudioAttachment:nil direction:INInteractionDirectionOutgoing]; + INInteraction* interaction = [[INInteraction alloc] initWithIntent:intent response:nil]; + interaction.direction = INInteractionDirectionOutgoing; + interaction.identifier = [NSString stringWithFormat:@"%@|%@", message.accountID, message.buddyName]; + [interaction donateInteractionWithCompletion:^(NSError *error) { + if(error) + DDLogError(@"Could not donate outgoing interaction: %@", error); + }]; +} + +-(INSendMessageIntent*) makeIntentForMessage:(MLMessage*) message usingText:(NSString*) msgText andAudioAttachment:(INSendMessageAttachment*) audioAttachment direction:(INInteractionDirection) direction +{ + // some docu: + // - https://developer.apple.com/documentation/usernotifications/implementing_communication_notifications?language=objc + // - https://gist.github.com/Dexwell/dedef7389eae26c5b9db927dc5588905 + // - https://stackoverflow.com/a/68705169/3528174 + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:message.accountID]; + MLContact* contact = [MLContact createContactFromJid:message.buddyName andAccountID:message.accountID]; + INPerson* sender = nil; + NSString* groupDisplayName = nil; + NSMutableArray* recipients = [NSMutableArray new]; + if(message.isMuc) + { + groupDisplayName = contact.contactDisplayName; + //we don't need different handling of incoming or outgoing messages for non-anon mucs because sender and receiver always contain the right contacts + if([kMucTypeGroup isEqualToString:message.mucType] && message.participantJid) + { + MLContact* contactInGroup = [MLContact createContactFromJid:message.participantJid andAccountID:message.accountID]; + //use MLMessage's capability to calculate the fallback name using actualFrom + sender = [self makeINPersonWithContact:contactInGroup andDisplayName:message.contactDisplayName andAccount:account]; + + //add other group members + for(NSDictionary* member in [[DataLayer sharedInstance] getMembersAndParticipantsOfMuc:message.buddyName forAccountID:message.accountID]) + { + MLContact* contactInGroup = [MLContact createContactFromJid:emptyDefault(member[@"participant_jid"], @"", member[@"member_jid"]) andAccountID:message.accountID]; + [recipients addObject:[self makeINPersonWithContact:contactInGroup andDisplayName:member[@"room_nick"] andAccount:account]]; + } + } + else + { + //in anon mucs we have to flip sender and receiver to make sure iOS handles them correctly + if(direction == INInteractionDirectionIncoming) + { + //use MLMessage's capability to calculate the fallback name using actualFrom + sender = [self makeINPersonWithContact:contact andDisplayName:message.contactDisplayName andAccount:account]; + //the next 2 lines are needed to make iOS show the group name in notifications + [recipients addObject:[self makeINPersonForOwnAccount:account]]; + [recipients addObject:sender]; + } + else + { + //we always need a sender (that's us in the outgoing case) + sender = [self makeINPersonForOwnAccount:account]; + //use MLMessage's capability to calculate the fallback name using actualFrom + [recipients addObject:[self makeINPersonWithContact:contact andDisplayName:message.contactDisplayName andAccount:account]]; + [recipients addObject:sender]; //match the recipients array for the incoming case above + } + } + } + else + { + //in 1:1 messages we have to flip sender and receiver to make sure iOS adds the correct share suggestions to its list + if(direction == INInteractionDirectionIncoming) + { + sender = [self makeINPersonWithContact:contact andDisplayName:nil andAccount:account]; + [recipients addObject:[self makeINPersonForOwnAccount:account]]; + } + else + { + sender = [self makeINPersonForOwnAccount:account]; + [recipients addObject:[self makeINPersonWithContact:contact andDisplayName:nil andAccount:account]]; + } + } + + //DDLogDebug(@"Creating INSendMessageIntent with recipients=%@, speakableGroupName=%@, sender=%@", recipients, groupDisplayName, sender); + INSendMessageIntent* intent = [[INSendMessageIntent alloc] initWithRecipients:recipients + outgoingMessageType:(audioAttachment ? INOutgoingMessageTypeOutgoingMessageAudio : INOutgoingMessageTypeOutgoingMessageText) + content:msgText + speakableGroupName:(groupDisplayName ? [[INSpeakableString alloc] initWithSpokenPhrase:groupDisplayName] : nil) + conversationIdentifier:[[NSString alloc] initWithData:[HelperTools serializeObject:contact] encoding:NSISOLatin1StringEncoding] + serviceName:message.accountID.stringValue + sender:sender + attachments:(audioAttachment ? @[audioAttachment] : @[])]; + //DDLogDebug(@"Intent is now: %@", intent); + if(message.isMuc) + { + if(contact.avatar != nil) + { + DDLogDebug(@"Using muc avatar image: %@", contact.avatar); + [intent setImage:[INImage imageWithImageData:UIImagePNGRepresentation(contact.avatar)] forParameterNamed:@"speakableGroupName"]; + } + else + DDLogDebug(@"NOT using avatar image..."); + } + + return intent; + + /* + if(message.isMuc) + { + [intent setImage:avatar forParameterNamed:"speakableGroupName"]; + [intent setImage:avatar forParameterNamed:"sender"]; + } + else + [intent setImage:avatar forParameterNamed:"sender"]; + */ + + /* + INCallRecord* callRecord = [[INCallRecord alloc] initWithIdentifier:[self threadIdentifierWithMessage:message] + dateCreated:[NSDate date] + callRecordType:INCallRecordTypeOutgoing + callCapability:INCallCapabilityAudioCall + callDuration:@0 + unseen:@YES]; + INStartCallIntent* intent = [[INStartCallIntent alloc] initWithCallRecordFilter:nil + callRecordToCallBack:callRecord + audioRoute:INCallAudioRouteUnknown + destinationType:INCallDestinationTypeNormal + contacts:@[sender] + callCapability:INCallCapabilityAudioCall]; + */ +} + +-(INPerson*) makeINPersonForOwnAccount:(xmpp*) account +{ + DDLogDebug(@"Building INPerson for self contact..."); + INPersonHandle* personHandle = [[INPersonHandle alloc] initWithValue:account.connectionProperties.identity.jid type:INPersonHandleTypeUnknown label:@"Monal IM"]; + NSPersonNameComponents* nameComponents = [NSPersonNameComponents new]; + nameComponents.nickname = [MLContact ownDisplayNameForAccount:account]; + MLContact* ownContact = [MLContact createContactFromJid:account.connectionProperties.identity.jid andAccountID:account.accountID]; + INImage* contactImage = nil; + if(ownContact.avatar != nil) + { + DDLogDebug(@"Using own avatar image: %@", ownContact.avatar); + NSData* avatarData = UIImagePNGRepresentation(ownContact.avatar); + contactImage = [INImage imageWithImageData:avatarData]; + } + else + DDLogDebug(@"NOT using own avatar image..."); + INPerson* person = [[INPerson alloc] initWithPersonHandle:personHandle + nameComponents:nameComponents + displayName:nameComponents.nickname + image:contactImage + contactIdentifier:nil + customIdentifier:nil + isMe:YES + suggestionType:INPersonSuggestionTypeInstantMessageAddress]; + return person; +} + +-(INPerson*) makeINPersonWithContact:(MLContact*) contact andDisplayName:(NSString* _Nullable) displayName andAccount:(xmpp*) account +{ + DDLogDebug(@"Building INPerson for contact: %@ using display name: %@", contact, displayName); + if(displayName == nil) + displayName = contact.contactDisplayName; + INPersonHandle* personHandle = [[INPersonHandle alloc] initWithValue:contact.contactJid type:INPersonHandleTypeUnknown label:@"Monal IM"]; + NSPersonNameComponents* nameComponents = [NSPersonNameComponents new]; + nameComponents.nickname = displayName; + INImage* contactImage = nil; + if(contact.avatar != nil) + { + DDLogDebug(@"Using avatar image: %@", contact.avatar); + NSData* avatarData = UIImagePNGRepresentation(contact.avatar); + contactImage = [INImage imageWithImageData:avatarData]; + } + else + DDLogDebug(@"NOT using avatar image..."); + INPerson* person = [[INPerson alloc] initWithPersonHandle:personHandle + nameComponents:nameComponents + displayName:nameComponents.nickname + image:contactImage + contactIdentifier:nil + customIdentifier:nil + isMe:account.connectionProperties.identity.jid == contact.contactJid + suggestionType:INPersonSuggestionTypeInstantMessageAddress]; + /* + if(contact.isInRoster) + person.relationship = INPersonRelationshipFriend; + */ + return person; +} + +-(void) showLegacyNotificationForMessage:(MLMessage*) message withSound:(BOOL) sound +{ + UNMutableNotificationContent* content = [UNMutableNotificationContent new]; + MLContact* contact = [MLContact createContactFromJid:message.buddyName andAccountID:message.accountID]; + NSString* idval = [self identifierWithMessage:message]; + + //Only show contact name if allowed + if(self.notificationPrivacySetting <= NotificationPrivacySettingOptionDisplayOnlyName) + { + content.title = [contact contactDisplayName]; + if(message.isMuc) + content.subtitle = [NSString stringWithFormat:NSLocalizedString(@"%@ says:", @""), message.contactDisplayName]; + } + else + content.title = NSLocalizedString(@"New Message", @""); + + //only show msgText if allowed + if(self.notificationPrivacySetting == NotificationPrivacySettingOptionDisplayNameAndMessage) + { + NSString* msgText = message.messageText; + + //XEP-0245: The slash me Command + if([message.messageText hasPrefix:@"/me "]) + msgText = [[MLXEPSlashMeHandler sharedInstance] stringSlashMeWithMessage:message]; + + content.body = msgText; + content.threadIdentifier = [self threadIdentifierWithMessage:message]; + content.categoryIdentifier = @"message"; + //don't simply use contact directly to make sure we always use a freshly created up to date contact when unpacking the userInfo dict + content.userInfo = @{ + @"fromContactJid": message.buddyName, + @"fromContactAccountID": message.accountID, + @"messageId": message.messageId + }; + + if(sound && [[HelperTools defaultsDB] boolForKey:@"Sound"]) + { + NSString* filename = [[HelperTools defaultsDB] objectForKey:@"AlertSoundFile"]; + if(filename) + { + content.sound = [UNNotificationSound soundNamed:[NSString stringWithFormat:@"AlertSounds/%@.aif", filename]]; + DDLogDebug(@"Using user configured alert sound: %@", content.sound); + } + else + { + content.sound = [UNNotificationSound defaultSound]; + DDLogDebug(@"Using default alert sound: %@", content.sound); + } + } + else + DDLogDebug(@"Using no alert sound"); + + if([message.messageType isEqualToString:kMessageTypeFiletransfer]) + { + NSDictionary* info = [MLFiletransfer getFileInfoForMessage:message]; + if(info) + { + NSString* mimeType = info[@"mimeType"]; + if([mimeType hasPrefix:@"image/"]) + { + content.body = NSLocalizedString(@"Sent an Image 📷", @""); + + UNNotificationAttachment* attachment; + if(![info[@"needsDownloading"] boolValue]) + { + UTType* typeHint = [UTType typeWithMIMEType:mimeType]; + if(typeHint == nil) + typeHint = UTTypeImage; + attachment = [self createNotificationAttachmentForFileInfo:info havingTypeHint:typeHint]; + if(attachment) + { + content.attachments = @[attachment]; + content.body = @""; + } + } + } + else if([mimeType hasPrefix:@"image/"]) + content.body = NSLocalizedString(@"📷 An Image", @""); + else if([mimeType hasPrefix:@"audio/"]) + content.body = NSLocalizedString(@"🎵 An Audiomessage", @""); + else if([mimeType hasPrefix:@"video/"]) + content.body = NSLocalizedString(@"🎥 A Video", @""); + else if([mimeType isEqualToString:@"application/pdf"]) + content.body = NSLocalizedString(@"📄 A Document", @""); + else + content.body = NSLocalizedString(@"Sent a File 📁", @""); + } + else + { + // empty info dict default to "Sent a file" + content.body = NSLocalizedString(@"Sent a File 📁", @""); + } + } + else if([message.messageType isEqualToString:kMessageTypeUrl] && [[HelperTools defaultsDB] boolForKey:@"ShowURLPreview"]) + content.body = NSLocalizedString(@"Sent a Link 🔗", @""); + else if([message.messageType isEqualToString:kMessageTypeGeo]) + content.body = NSLocalizedString(@"Sent a Location 📍", @""); + } + else + content.body = NSLocalizedString(@"Open app to see more", @""); + + DDLogDebug(@"Publishing notification with id %@", idval); + [self publishNotificationContent:[self updateBadgeForContent:content] withID:idval]; +} + +-(UNNotificationAttachment* _Nullable) createNotificationAttachmentForFileInfo:(NSDictionary*) info havingTypeHint:(UTType*) typeHint +{ + NSError* error; + NSString* attachmentDir = [[HelperTools getContainerURLForPathComponents:@[@"documentCache"]] path]; + //use "tmp." prefix to make sure this file will be garbage collected should the ios notification attachment implementation leave it behind + NSString* attachmentBasename = [NSString stringWithFormat:@"tmp.%@", info[@"cacheId"]]; + NSString* notificationAttachment = [attachmentDir stringByAppendingPathComponent:[attachmentBasename stringByAppendingPathExtensionForType:typeHint]]; + //using stringByAppendingPathExtensionForType: does not produce playable audio notifications for audios sent by conversations, + //but seems to work for other types + //--> use info[@"fileExtension"] for audio files and stringByAppendingPathExtensionForType: for all other types + if([typeHint conformsToType:UTTypeAudio]) + notificationAttachment = [notificationAttachment stringByAppendingPathComponent:[attachmentBasename stringByAppendingPathExtension:info[@"fileExtension"]]]; + UIImage* image = nil; + if([info[@"mimeType"] hasPrefix:@"image/svg"]) + { + NSString* pngAttachment = [attachmentDir stringByAppendingPathComponent:[attachmentBasename stringByAppendingPathExtensionForType:UTTypePNG]]; + DDLogVerbose(@"Preparing for notification attachment(%@): converting downloaded file from svg at '%@' to png at '%@'...", typeHint, info[@"cacheFile"], pngAttachment); + //we want our code to run synchronously --> use PMKHang + //this code should never run in the main queue to not provoke a deadlock + if([NSThread isMainThread]) + @throw [NSException exceptionWithName:@"InvalidThread" reason:@"PMKHang on renderUIImageFromSVGURL must never be called on the main thread!" userInfo:nil]; + image = (UIImage*)nilExtractor(PMKHang([HelperTools renderUIImageFromSVGURL:[NSURL fileURLWithPath:info[@"cacheFile"]]])); + if(image != nil) + { + [UIImagePNGRepresentation(image) writeToFile:pngAttachment atomically:YES]; + typeHint = UTTypePNG; + notificationAttachment = pngAttachment; + } + } + //fallback if svg extraction failed OR it wasn't an SVG image in the first place + if(image == nil) + { + DDLogVerbose(@"Preparing for notification attachment(%@): hardlinking downloaded file from '%@' to '%@'...", typeHint, info[@"cacheFile"], notificationAttachment); + error = [HelperTools hardLinkOrCopyFile:info[@"cacheFile"] to:notificationAttachment]; + if(error) + { + DDLogError(@"Could not hardlink cache file to notification image temp file!"); + return nil; + } + } + [HelperTools configureFileProtectionFor:notificationAttachment]; + UNNotificationAttachment* attachment = [UNNotificationAttachment attachmentWithIdentifier:info[@"cacheId"] URL:[NSURL fileURLWithPath:notificationAttachment] options:@{UNNotificationAttachmentOptionsTypeHintKey:typeHint} error:&error]; + if(error != nil) + DDLogError(@"Could not create UNNotificationAttachment: %@", error); + return attachment; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +@end diff --git a/Monal/Classes/MLNotificationQueue.h b/Monal/Classes/MLNotificationQueue.h new file mode 100644 index 0000000..7994561 --- /dev/null +++ b/Monal/Classes/MLNotificationQueue.h @@ -0,0 +1,30 @@ +// +// MLNotificationQueue.h +// Monal +// +// Created by Thilo Molitor on 03.04.21. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLNotificationQueue : NSObject + ++(void) queueNotificationsInBlock:(monal_void_block_t) block onQueue:(NSString*) queueName; +-(NSUInteger) flush; +-(NSUInteger) clear; + ++(id) currentQueue; +-(void) postNotificationName:(NSNotificationName) notificationName object:(id _Nullable) notificationObject userInfo:(id _Nullable) notificationUserInfo; +-(void) postNotificationName:(NSNotificationName) notificationName object:(id _Nullable) notificationObject; +-(void) postNotification:(NSNotification* _Nonnull) notification; + +@property (readonly, strong) NSString* name; +-(NSString*) description; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLNotificationQueue.m b/Monal/Classes/MLNotificationQueue.m new file mode 100644 index 0000000..a49c62f --- /dev/null +++ b/Monal/Classes/MLNotificationQueue.m @@ -0,0 +1,154 @@ +// +// MLNotificationQueue.m +// monalxmpp +// +// Created by Thilo Molitor on 03.04.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import "MLNotificationQueue.h" + +@interface MLNotificationQueue() +{ + NSString* _queueName; + NSMutableArray* _entries; + id _lowerQueue; //use id because this could be an MLNotificationQueue *or* [NSNotificationCenter defaultCenter] +} ++(NSMutableArray*) getThreadLocalNotificationQueueStack; +@end + +@implementation MLNotificationQueue + +//this is a contextmanager (like the ones found in python) ++(void) queueNotificationsInBlock:(monal_void_block_t) block onQueue:(NSString*) queueName +{ + NSMutableArray* stack = [self getThreadLocalNotificationQueueStack]; + for(MLNotificationQueue* queue in stack) + if([queue.name isEqualToString:queueName]) + @throw [NSException exceptionWithName:@"NotificationQueueException" reason:[NSString stringWithFormat:@"Tried to instanciate queue twice: %@", queueName] userInfo:@{ + @"stack": stack, + @"alreadyExistingQueue": queue, + }]; + //create new notification queue and put it onto our stack of queues + MLNotificationQueue* queue = [[self alloc] initWithName:queueName]; + [stack addObject:queue]; + //call the context our contextmanager manages (a monal_void_block_t block) + block(); + //remove own queue from stack again + [stack removeLastObject]; + //flush the queue to the next queue in our stack (or send them to the notification center if no queue is left on the stack) + //don't use the flush deallocate because we want our flush to be "inline" thread-wise + [queue flush]; + //this will deallocate our queue (flushing was already done before) + queue = nil; +} + ++(id) currentQueue +{ + NSMutableArray* stack = [self getThreadLocalNotificationQueueStack]; + if(![stack count]) + return [NSNotificationCenter defaultCenter]; + return [stack lastObject]; +} + +//this is compatible to [NSNotificationCenter defaultCenter] +-(void) postNotificationName:(NSNotificationName) notificationName object:(id _Nullable) notificationObject userInfo:(id _Nullable) notificationUserInfo +{ + DDLogDebug(@"Queueing notification: %@, object = %@, userInfo = %@", notificationName, notificationObject, notificationUserInfo); + //create queue entry (handle nil arguments) + NSMutableDictionary* entry = [NSMutableDictionary new]; + entry[@"name"] = notificationName; + if(notificationObject != nil) + entry[@"obj"] = notificationObject; + if(notificationUserInfo != nil) + entry[@"userInfo"] = notificationUserInfo; + + //add entry to our queue + @synchronized(_entries) { + [_entries addObject:entry]; + } +} + +//this is compatible to [NSNotificationCenter defaultCenter] +-(void) postNotificationName:(NSNotificationName) notificationName object:(id _Nullable) notificationObject +{ + [self postNotificationName:notificationName object:notificationObject userInfo:nil]; +} + +//this is compatible to [NSNotificationCenter defaultCenter] +-(void) postNotification:(NSNotification*) notification +{ + [self postNotificationName:notification.name object:notification.object userInfo:notification.userInfo]; +} + +-(NSUInteger) flush +{ + DDLogDebug(@"Flushing queue '%@', current stack: %@", [self name], [[[[self class] getThreadLocalNotificationQueueStack] reverseObjectEnumerator] allObjects]); + NSArray* toFlush; + @synchronized(_entries) { + toFlush = _entries; + _entries = [NSMutableArray new]; + } + DDLogVerbose(@"Notifications in queue '%@': %@", [self name], toFlush); + for(NSDictionary* entry in toFlush) + [_lowerQueue postNotificationName:entry[@"name"] object:entry[@"obj"] userInfo:entry[@"userInfo"]]; + @synchronized(_entries) { + if([_entries count]) + @throw [NSException exceptionWithName:@"NotificationQueueException" reason:[NSString stringWithFormat:@"Tried to add more entries to queue while flushing: %@", _queueName] userInfo:nil]; + } + DDLogVerbose(@"Done flushing %@ notifications in queue '%@'", @([toFlush count]), [self name]); + return [toFlush count]; +} + +-(NSUInteger) clear +{ + DDLogDebug(@"Clearing queue '%@', current stack: %@", [self name], [[[[self class] getThreadLocalNotificationQueueStack] reverseObjectEnumerator] allObjects]); + NSUInteger retval; + @synchronized(_entries) { + retval = [_entries count]; + _entries = [NSMutableArray new]; + } + return retval; +} + +-(NSString*) name +{ + return _queueName; +} + +-(NSString*) description +{ + NSMutableArray* queuedNotificationNames = [NSMutableArray new]; + @synchronized(_entries) { + for(NSDictionary* entry in _entries) + [queuedNotificationNames addObject:entry[@"name"]]; + } + return [NSString stringWithFormat:@"%@: %@", self.name, queuedNotificationNames]; +} + ++(NSMutableArray*) getThreadLocalNotificationQueueStack +{ + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + //init dictionaries if neccessary + if(!threadData[@"_notificationQueueStack"]) + threadData[@"_notificationQueueStack"] = [NSMutableArray new]; + return threadData[@"_notificationQueueStack"]; +} + +-(instancetype) initWithName:(NSString*) queueName +{ + self = [super init]; + _queueName = queueName; + _entries = [NSMutableArray new]; + _lowerQueue = [MLNotificationQueue currentQueue]; + return self; +} + +-(void) dealloc +{ + //there should only be one thread calling dealloc ever (per objc runtime) --> no @synchronized needed + if([_entries count]) + [self flush]; +} + +@end diff --git a/Monal/Classes/MLOMEMO.h b/Monal/Classes/MLOMEMO.h new file mode 100644 index 0000000..69e5a2f --- /dev/null +++ b/Monal/Classes/MLOMEMO.h @@ -0,0 +1,58 @@ +// +// MLOMEMO.h +// Monal +// +// Created by Friedrich Altheide on 21.06.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "OmemoState.h" +#import "MLSignalStore.h" + +NS_ASSUME_NONNULL_BEGIN + +@class MLSignalStore; +@class SignalAddress; +@class xmpp; +@class XMPPMessage; +@class XMPPIQ; +@class MLXMLNode; + +@interface MLOMEMO : NSObject +@property (nonatomic, strong) OmemoState* state; +@property (nonatomic) unsigned long openBundleFetchCnt; +@property (nonatomic) unsigned long closedBundleFetchCnt; + +-(MLOMEMO*) initWithAccount:(xmpp*) account; +-(void) activate; + +/* + * encrypting / decrypting messages + */ +-(MLXMLNode* _Nullable) encryptString:(NSString* _Nullable) message toDeviceids:(NSDictionary*>*) contactDeviceMap; +-(void) encryptMessage:(XMPPMessage*) messageNode withMessage:(NSString* _Nullable) message toContact:(NSString*) toContact; +-(NSString* _Nullable) decryptOmemoEnvelope:(MLXMLNode*) envelope forSenderJid:(NSString*) senderJid andReturnErrorString:(BOOL) returnErrorString; +-(NSString* _Nullable) decryptMessage:(XMPPMessage*) messageNode withMucParticipantJid:(NSString* _Nullable) mucParticipantJid; + +-(NSSet*) knownDevicesForAddressName:(NSString*) addressName; +-(BOOL) isTrustedIdentity:(SignalAddress*)address identityKey:(NSData*)identityKey; +-(void) addIdentityManually:(SignalAddress*) address identityKey:(NSData* _Nonnull) identityKey; +-(void) updateTrust:(BOOL) trust forAddress:(SignalAddress*)address; +-(NSNumber*) getTrustLevel:(SignalAddress*)address identityKey:(NSData*)identityKey; +-(NSNumber* _Nullable) getTrustLevelForJid:(NSString*) jid andDeviceId:(NSNumber*) deviceid; +-(NSData*) getIdentityForAddress:(SignalAddress*) address; +-(BOOL) isSessionBrokenForJid:(NSString*) jid andDeviceId:(NSNumber*) rid; +-(void) deleteDeviceForSource:(NSString*) source andRid:(NSNumber*) rid; + +-(void) subscribeAndFetchDevicelistIfNoSessionExistsForJid:(NSString*) buddyJid; +-(void) checkIfSessionIsStillNeeded:(NSString*) buddyJid isMuc:(BOOL) isMuc; +-(NSNumber*) getDeviceId; + +-(void) untrustAllDevicesFrom:(NSString*) jid; + +//debug button in contactdetails ui +-(void) clearAllSessionsForJid:(NSString*) jid; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLOMEMO.m b/Monal/Classes/MLOMEMO.m new file mode 100644 index 0000000..f0d40b2 --- /dev/null +++ b/Monal/Classes/MLOMEMO.m @@ -0,0 +1,1442 @@ +// +// MLOMEMO.m +// Monal +// +// Created by Friedrich Altheide on 21.06.20. +// Copyright © 2020 Monal.im. All rights reserved. +// +#import +#import + +#import "MLOMEMO.h" +#import "MLXMPPConnection.h" +#import "MLHandler.h" +#import "xmpp.h" +#import "XMPPMessage.h" +#import "SignalAddress.h" +#import "MLSignalStore.h" +#import "SignalContext.h" +#import "AESGcm.h" +#import "HelperTools.h" +#import "XMPPIQ.h" +#import "xmpp.h" +#import "MLPubSub.h" +#import "DataLayer.h" +#import "MLNotificationQueue.h" + +NS_ASSUME_NONNULL_BEGIN + +static const size_t MIN_OMEMO_KEYS = 25; +static const size_t MAX_OMEMO_KEYS = 100; +static const int KEY_SIZE = 16; + +@interface MLOMEMO () +{ + OmemoState* _state; +} +@property (nonatomic, weak) xmpp* account; +@property (nonatomic, strong) MLSignalStore* monalSignalStore; +@property (nonatomic, strong) SignalContext* signalContext; +@property (nonatomic, strong) NSMutableSet* ownDeviceList; +@end + +@implementation MLOMEMO + +-(MLOMEMO*) initWithAccount:(xmpp*) account; +{ + self = [super init]; + self.account = account; + self.monalSignalStore = [[MLSignalStore alloc] initWithAccountID:self.account.accountID andAccountJid:self.account.connectionProperties.identity.jid]; + SignalStorage* signalStorage = [[SignalStorage alloc] initWithSignalStore:self.monalSignalStore]; + self.signalContext = [[SignalContext alloc] initWithStorage:signalStorage]; + self.openBundleFetchCnt = 0; + self.closedBundleFetchCnt = 0; + + //_state is intentionally left unset and will be updated from [xmpp readState] before [self activate] is called + //(but only if the state wasn't invalidated, in which case [self activate] will create a new empty state) + return self; +} + +-(void) activate +{ + if(self->_state == nil) + self->_state = [OmemoState new]; + + //read own devicelist from database + self.ownDeviceList = [[self knownDevicesForAddressName:self.account.connectionProperties.identity.jid] mutableCopy]; + DDLogVerbose(@"Own devicelist for account %@ is now: %@", self.account, self.ownDeviceList); + DDLogVerbose(@"Deviceid of this device: %@", @(self.monalSignalStore.deviceid)); + + [self createLocalIdentiyKeyPairIfNeeded]; + + //init pubsub devicelist handler + [self.account.pubsub registerForNode:@"eu.siacs.conversations.axolotl.devicelist" withHandler:$newHandler(self, devicelistHandler)]; + + //register notification handler + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleContactRemoved:) name:kMonalContactRemoved object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleHasLoggedIn:) name:kMLIsLoggedInNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleResourceBound:) name:kMLResourceBoundNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleCatchupDone:) name:kMonalFinishedCatchup object:nil]; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(void) setState:(OmemoState*) state +{ + [self->_state updateWith:state]; +} + +-(OmemoState*) state +{ + return self->_state; +} + +//updateIfIdNotEqual(self.contactJid, contact.contactJid); + +-(NSSet*) knownDevicesForAddressName:(NSString*) addressName +{ + return [NSSet setWithArray:[self.monalSignalStore knownDevicesForAddressName:addressName]]; +} + +-(void) notifyKnownDevicesUpdated:(NSString*) jid +{ + [[MLNotificationQueue currentQueue] postNotificationName:kMonalOmemoStateUpdated object:self.account userInfo:@{ + @"jid": jid + }]; +} + +-(BOOL) createLocalIdentiyKeyPairIfNeeded +{ + if(self.monalSignalStore.deviceid == 0) + { + //signal key helper + SignalKeyHelper* signalHelper = [[SignalKeyHelper alloc] initWithContext:self.signalContext]; + + //Generate a new device id + do { + self.monalSignalStore.deviceid = [signalHelper generateRegistrationId]; + } while(self.monalSignalStore.deviceid == 0 || [self.ownDeviceList containsObject:[NSNumber numberWithUnsignedInt:self.monalSignalStore.deviceid]]); + //Create identity key pair + self.monalSignalStore.identityKeyPair = [signalHelper generateIdentityKeyPair]; + self.monalSignalStore.signedPreKey = [signalHelper generateSignedPreKeyWithIdentity:self.monalSignalStore.identityKeyPair signedPreKeyId:1]; + SignalAddress* address = [[SignalAddress alloc] initWithName:self.account.connectionProperties.identity.jid deviceId:self.monalSignalStore.deviceid]; + [self.monalSignalStore saveIdentity:address identityKey:self.monalSignalStore.identityKeyPair.publicKey]; + //do everything done in MLSignalStore init not already mimicked above + [self.monalSignalStore cleanupKeys]; + [self.monalSignalStore reloadCachedPrekeys]; + [self notifyKnownDevicesUpdated:address.name]; + //we generated a new identity + DDLogWarn(@"Created new omemo identity with deviceid: %@", @(self.monalSignalStore.deviceid)); + //don't alert on new deviceids we could never see before because this is our first connection (otherwise, we'd already have our own deviceid) + //this has to be a property of the xmpp class to persist it even across state resets + self.account.hasSeenOmemoDeviceListAfterOwnDeviceid = NO; + return YES; + } + //we did not generate a new identity + //keep the value of hasSeenOmemoDeviceListAfterOwnDeviceid in this case + return NO; +} + +-(void) handleContactRemoved:(NSNotification*) notification +{ +#ifndef DISABLE_OMEMO + MLContact* removedContact = notification.userInfo[@"contact"]; + DDLogVerbose(@"Got kMonalContactRemoved event for contact: %@", removedContact); + if(removedContact == nil || removedContact.accountID.intValue != self.account.accountID.intValue) + return; + + [self checkIfSessionIsStillNeeded:removedContact.contactJid isMuc:removedContact.isMuc]; +#endif +} + +-(void) handleHasLoggedIn:(NSNotification*) notification +{ + //this event will be called as soon as we are successfully authenticated, but BEFORE handleResourceBound: will be called + //NOTE: handleResourceBound: won't be called for smacks resumptions at all +#ifndef DISABLE_OMEMO + if(self.account.accountID.intValue == ((xmpp*)notification.object).accountID.intValue) + { + //mark catchup as running (will be smacks catchup or mam catchup) + //this will queue any session repair attempts and key transport elements + self.state.catchupDone = NO; + } +#endif +} + +-(void) handleResourceBound:(NSNotification*) notification +{ + //this event will be called as soon as we are bound, but BEFORE mam catchup happens + //NOTE: this event won't be called for smacks resumes! +#ifndef DISABLE_OMEMO + if(self.account.accountID.intValue == ((xmpp*)notification.object).accountID.intValue) + { + DDLogInfo(@"We did a non-smacks-resume reconnect, resetting some of our state..."); + DDLogVerbose(@"Current state: %@", self.state); + + //we bound a new xmpp session --> reset our whole state + self.openBundleFetchCnt = 0; + self.closedBundleFetchCnt = 0; + self.state.openBundleFetches = [NSMutableDictionary new]; + self.state.openDevicelistFetches = [NSMutableSet new]; + self.state.openDevicelistSubscriptions = [NSMutableSet new]; + self.ownDeviceList = [[self knownDevicesForAddressName:self.account.connectionProperties.identity.jid] mutableCopy]; + DDLogVerbose(@"Own devicelist for account %@ is now: %@", self.account, self.ownDeviceList); + + //we will get our own devicelist when sending our first presence after being bound (because we are using +notify for the devicelist) + self.state.hasSeenDeviceList = NO; + + //the catchup is still pending after being bound (mam catchup) + self.state.catchupDone = NO; + + DDLogVerbose(@"New state: %@", self.state); + } +#endif +} + +-(void) handleCatchupDone:(NSNotification*) notification +{ +#ifndef DISABLE_OMEMO + //this event will be called as soon as mam OR smacks catchup on our account is done, it does not wait for muc mam catchups! + if(self.account.accountID.intValue == ((xmpp*)notification.object).accountID.intValue) + { + DDLogInfo(@"Catchup done now, handling omemo stuff..."); + DDLogVerbose(@"Current state: %@", self.state); + + //the catchup completed now + self.state.catchupDone = YES; + + //if we did not see our own devicelist until now that means the server does not have any devicelist stored + //OR: our own devicelist could have been delayed by the server having to do a disco query to us to discover our +notify + //for the devicelist (e.g. either we are the first omemo capable client, or the devicelist has just been delayed) + //--> forcefully fetch devicelist to be sure (but don't subscribe, we are +notify and have a presence subscription to our own account) + //If our device is not listed in this devicelist node, that fetch and the headline push eventually coming in + //may both trigger a devicelist publish, but that should not do any harm + if(self.state.hasSeenDeviceList == NO) + { + DDLogInfo(@"We did not see any devicelist during catchup since last non-smacks-resume reconnect, forcefully fetching own devicelist..."); + [self queryOMEMODevices:self.account.connectionProperties.identity.jid withSubscribe:NO]; + } + else + { + [self generateNewKeysIfNeeded]; //generate new prekeys if needed and publish them + [self repairQueuedSessions]; + } + } +#endif +} + +-(void) handleOwnDevicelistFetchError +{ + //devicelist could neither be fetched explicitly nor by using +notify --> publish own devicelist by faking an empty server-sent devicelist + //self.state.hasSeenDeviceList will be set to YES once the published devicelist gets returned to us by a pubsub headline echo + //(e.g. once the devicelist was safely stored on our server) + DDLogInfo(@"Could not fetch own devicelist, faking empty devicelist to publish our own deviceid..."); + [self processOMEMODevices:[NSSet new] from:self.account.connectionProperties.identity.jid]; + + [self repairQueuedSessions]; +} + +-(void) repairQueuedSessions +{ + DDLogInfo(@"Own devicelist was handled, now trying to repair queued sessions..."); + + //send all needed key transport elements now (added by incoming catchup messages or bundle fetches) + //the queue is needed to make sure we won't send multiple key transport messages to a single contact/device + //only because we received multiple messages from this user in the catchup or fetched multiple bundles + //queuedKeyTransportElements will survive any smacks or non-smacks resumptions and eventually trigger key transport elements + //once the catchup could be finished (could take several smacks resumptions to finish the whole (mam) catchup) + //has to be synchronized because [xmpp sendMessage:] could be called from main thread + @synchronized(self.state.queuedKeyTransportElements) { + DDLogDebug(@"Replaying queuedKeyTransportElements for all jids: %@", self.state.queuedKeyTransportElements); + for(NSString* jid in [self.state.queuedKeyTransportElements allKeys]) + [self retriggerKeyTransportElementsForJid:jid]; + } + + //handle all broken sessions now (e.g. reestablish them by fetching their bundles and sending key transport elements afterwards) + //the code handling the fetched bundle will check for an entry in queuedSessionRepairs and send + //a key transport element if such an entry can be found + //it removes the entry in queuedSessionRepairs afterwards, so no need to remove it here + //queuedSessionRepairs will survive a non-smacks relogin and trigger these dropped bundle fetches again to complete them + //has to be synchronized because [xmpp sendMessage:] could be called from main thread + @synchronized(self.state.queuedSessionRepairs) { + DDLogDebug(@"Replaying queuedSessionRepairs: %@", self.state.queuedSessionRepairs); + for(NSString* jid in self.state.queuedSessionRepairs) + for(NSNumber* rid in self.state.queuedSessionRepairs[jid]) + [self queryOMEMOBundleFrom:jid andDevice:rid]; + } + + //check bundle fetch status and inform ui if we are now catchupDone *and* all bundles are fetched + //(this method is only called by the catchupDone handler above or by the devicelist fetch triggered by the catchupDone handler) + [self checkBundleFetchCount]; + + DDLogVerbose(@"New state: %@", self.state); +} + +-(void) retriggerKeyTransportElementsForJid:(NSString*) jid +{ + //send all needed key transport elements now (added by incoming catchup messages or bundle fetches) + //the queue is needed to make sure we won't send multiple key transport messages to a single contact/device + //only because we received multiple messages from this user in the catchup or fetched multiple bundles + //queuedKeyTransportElements will survive any smacks or non-smacks resumptions and eventually trigger key transport elements + //once the catchup could be finished (could take several smacks resumptions to finish the whole (mam) catchup) + //has to be synchronized because [xmpp sendMessage:] could be called from main thread + @synchronized(self.state.queuedKeyTransportElements) { + NSMutableSet* rids = self.state.queuedKeyTransportElements[jid]; + if(rids == nil) + { + DDLogVerbose(@"No key transport elements queued for %@", jid); + return; + } + DDLogDebug(@"Replaying queuedKeyTransportElements for %@: %@", jid, rids); + //rids can be added back by sendKeyTransportElement: if the sending is still blocked by open bundle fetches etc. + [self.state.queuedKeyTransportElements removeObjectForKey:jid]; + [self sendKeyTransportElement:jid forRids:rids]; + } +} + +$$instance_handler(devicelistHandler, account.omemo, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$ID(NSString*, type), $_ID((NSDictionary*), data)) + //type will be "publish", "retract", "purge" or "delete". "publish" and "retract" will have the data dictionary filled with id --> data pairs + //the data for "publish" is the item node with the given id, the data for "retract" is always @YES + MLAssert([node isEqualToString:@"eu.siacs.conversations.axolotl.devicelist"], @"pep node must be 'eu.siacs.conversations.axolotl.devicelist'"); + NSSet* deviceIds = [NSSet new]; //default value used for retract, purge and delete + if([type isEqualToString:@"publish"]) + { + MLXMLNode* publishedDevices = [data objectForKey:@"current"]; + if(publishedDevices == nil && data.count == 1) + { + DDLogInfo(@"Client does not use 'current' as item id for it's bundle! keys=%@", [data allKeys]); + //some clients do not use + publishedDevices = [[data allValues] firstObject]; + } + else if(publishedDevices == nil && data.count > 1) + DDLogWarn(@"More than one devicelist item found from %@, ignoring all items!", jid); + + if(publishedDevices != nil) + deviceIds = [[NSSet alloc] initWithArray:[publishedDevices find:@"{eu.siacs.conversations.axolotl}list/device@id|uint"]]; + } + + //this will add our own deviceid if the devicelist is our own and our deviceid is missing + [self processOMEMODevices:deviceIds from:jid]; + + //mark our own devicelist as received (e.g. not empty on the server) + if([jid isEqualToString:self.account.connectionProperties.identity.jid]) + { + DDLogInfo(@"Marking our own devicelist as seen now..."); + self.state.hasSeenDeviceList = YES; + } +$$ + +-(void) queryOMEMODevices:(NSString*) jid withSubscribe:(BOOL) subscribe +{ + //don't fetch devicelist twice (could be triggered by multiple useractions in a row) + if([self.state.openDevicelistFetches containsObject:jid]) + DDLogInfo(@"Deduplicated devicelist fetches from %@", jid); + else + { + //fetch newest devicelist (this is needed even after a subscribe on at least prosody) + [self.account.pubsub fetchNode:@"eu.siacs.conversations.axolotl.devicelist" from:jid withItemsList:nil andHandler:$newHandlerWithInvalidation(self, handleDevicelistFetch, handleDevicelistFetchInvalidation, $BOOL(subscribe))]; + [self.state.openDevicelistFetches addObject:jid]; + + [self sendFetchUpdateNotificationForJid:jid]; + } +} + +$$instance_handler(handleDevicelistSubscribeInvalidation, account.omemo, $$ID(xmpp*, account), $$ID(NSString*, jid)) + //mark devicelist subscription as done + [self.state.openDevicelistSubscriptions removeObject:jid]; + + [self sendFetchUpdateNotificationForJid:jid]; +$$ + +$$instance_handler(handleDevicelistSubscribe, account.omemo, $$ID(xmpp*, account), $$ID(NSString*, jid), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + [self.state.openDevicelistSubscriptions removeObject:jid]; + + if(success == NO) + { + // TODO: improve error handling + if(errorIq) + DDLogError(@"Error while subscribe to omemo deviceslist from: %@ - %@", jid, errorIq); + else + DDLogError(@"Error while subscribe to omemo deviceslist from: %@ - %@", jid, errorReason); + } + + [self sendFetchUpdateNotificationForJid:jid]; +$$ + +$$instance_handler(handleDevicelistFetchInvalidation, account.omemo, $$ID(xmpp*, account), $$ID(NSString*, jid)) + //mark devicelist fetch as done + [self.state.openDevicelistFetches removeObject:jid]; + + //our own devicelist fetch can't be invalidated because of a iq timeout introduced by a slow s2s connection + //--> the only reason for such an invalidation can be a disconnect/bind and in this case we don't need to do something + // because the fetch will be retriggered after the next catchup + //[self handleOwnDevicelistFetchError]; + + //retrigger queued key transport elements for this jid (if any) + [self retriggerKeyTransportElementsForJid:jid]; + + [self sendFetchUpdateNotificationForJid:jid]; +$$ + +$$instance_handler(handleDevicelistFetch, account.omemo, $$ID(xmpp*, account), $$BOOL(subscribe), $$ID(NSString*, jid), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $_ID((NSDictionary*), data)) + //mark devicelist fetch as done + [self.state.openDevicelistFetches removeObject:jid]; + + if(success == NO) + { + if(errorIq) + DDLogError(@"Error while fetching omemo devices: jid: %@ - %@", jid, errorIq); + else + DDLogError(@"Error while fetching omemo devices: jid: %@ - %@", jid, errorReason); + if([self.account.connectionProperties.identity.jid isEqualToString:jid]) + [self handleOwnDevicelistFetchError]; + else + { + // TODO: improve error handling + } + } + else + { + if(subscribe && ![self.account.connectionProperties.identity.jid isEqualToString:jid]) + { + DDLogInfo(@"Successfully fetched devicelist, now subscribing to this node for updates..."); + //don't subscribe devicelist twice (could be triggered by multiple useractions in a row) + if([self.state.openDevicelistSubscriptions containsObject:jid]) + DDLogInfo(@"Deduplicated devicelist subscribe from %@", jid); + else + [self.account.pubsub subscribeToNode:@"eu.siacs.conversations.axolotl.devicelist" onJid:jid withHandler:$newHandlerWithInvalidation(self, handleDevicelistSubscribe, handleDevicelistSubscribeInvalidation)]; + } + + MLXMLNode* publishedDevices = [data objectForKey:@"current"]; + if(publishedDevices == nil && data.count == 1) + { + DDLogInfo(@"Client does not use 'current' as item id for it's bundle! keys=%@", [data allKeys]); + //some clients do not use + publishedDevices = [[data allValues] firstObject]; + } + else if(publishedDevices == nil && data.count > 1) + DDLogWarn(@"More than one devicelist item found from %@, ignoring all items!", jid); + + if(publishedDevices) + { + NSSet* deviceSet = [[NSSet alloc] initWithArray:[publishedDevices find:@"{eu.siacs.conversations.axolotl}list/device@id|uint"]]; + [self processOMEMODevices:deviceSet from:jid]; + } + + } + + if([self.account.connectionProperties.identity.jid isEqualToString:jid]) + [self repairQueuedSessions]; //now try to repair all broken sessions (our catchup is now really done) + else + [self retriggerKeyTransportElementsForJid:jid]; //retrigger queued key transport elements for this jid (if any) + + [self sendFetchUpdateNotificationForJid:jid]; +$$ + +-(void) processOMEMODevices:(NSSet*) receivedDevices from:(NSString*) source +{ + DDLogVerbose(@"Processing omemo devices from %@: %@", source, receivedDevices); + + NSMutableSet* existingDevices = [[self knownDevicesForAddressName:source] mutableCopy]; + // ensure that we refetch bundles of devices with broken bundles again after some time + NSSet* existingDevicesReqPendingFetch = [NSSet setWithArray:[self.monalSignalStore knownDevicesWithPendingBrokenSessionHandling:source]]; + [existingDevices minusSet:existingDevicesReqPendingFetch]; + + NSMutableSet* removedDevices = [existingDevices mutableCopy]; + [removedDevices minusSet:receivedDevices]; + DDLogVerbose(@"Removed devices detected: %@", removedDevices); + + //iterate through all received deviceids and query the corresponding bundle, if we don't know that deviceid yet + for(NSNumber* deviceId in receivedDevices) + { + //remove mark that the device was not found in the devicelist (if that mark was present) + [self.monalSignalStore removeDeviceDeletedMark:[[SignalAddress alloc] initWithName:source deviceId:deviceId.unsignedIntValue]]; + //fetch bundle of this device if it's a new device or if the session to this device is broken, but only do this for remote devices + //this will automatically send a key transport element to this device, once the bundle arrives and the session is still broken + if(![existingDevices containsObject:deviceId] && deviceId.unsignedIntValue != self.monalSignalStore.deviceid) + { + DDLogDebug(@"Device new or session broken, fetching bundle %@ (again)...", deviceId); + [self queryOMEMOBundleFrom:source andDevice:deviceId]; + } + } + + //remove devices from our signalStorage when they are no longer published + for(NSNumber* deviceId in removedDevices) + { + //only delete other devices from signal store but keep the entry for this device + if(![source isEqualToString:self.account.connectionProperties.identity.jid] || deviceId.unsignedIntValue != self.monalSignalStore.deviceid) + { + DDLogDebug(@"Removing device %@", deviceId); + SignalAddress* address = [[SignalAddress alloc] initWithName:source deviceId:deviceId.unsignedIntValue]; + [self.monalSignalStore markDeviceAsDeleted:address]; + } + } + + //remove deviceids from queuedSessionRepairs list if these devices are no longer available + @synchronized(self.state.queuedSessionRepairs) { + if(self.state.queuedSessionRepairs[source] != nil) + for(NSNumber* brokenRid in [self.state.queuedSessionRepairs[source] copy]) + if(![receivedDevices containsObject:brokenRid]) + { + DDLogDebug(@"Removing deviceid %@ on jid %@ from queuedSessionRepairs...", brokenRid, source); + [self.state.queuedSessionRepairs[source] removeObject:brokenRid]; + } + } + + //handle our own devicelist + if([self.account.connectionProperties.identity.jid isEqualToString:source]) + [self handleOwnDevicelistUpdate:receivedDevices]; + else + [self notifyKnownDevicesUpdated:source]; +} + +-(void) handleOwnDevicelistUpdate:(NSSet*) receivedDevices +{ + //check for new deviceids not previously known, but only if this isn't the first login we see a devicelist + //this has to be a property of the xmpp class to persist it even across state resets + if(self.account.hasSeenOmemoDeviceListAfterOwnDeviceid) + { + NSMutableSet* newDevices = [receivedDevices mutableCopy]; + [newDevices minusSet:self.ownDeviceList]; + //alert for all devices now still listed in newDevices + for(NSNumber* device in newDevices) + if([device unsignedIntValue] != self.monalSignalStore.deviceid) + { + DDLogWarn(@"Got new deviceid %@ for own account %@", device, self.account.connectionProperties.identity.jid); + UNMutableNotificationContent* content = [UNMutableNotificationContent new]; + content.title = NSLocalizedString(@"New omemo device", @"");; + content.subtitle = self.account.connectionProperties.identity.jid; + content.body = [NSString stringWithFormat:NSLocalizedString(@"Detected a new omemo device on your account: %@", @""), device]; + content.sound = [UNNotificationSound defaultSound]; + content.categoryIdentifier = @"simple"; + UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:[NSString stringWithFormat:@"newOwnOmemoDevice::%@::%@", self.account.connectionProperties.identity.jid, device] content:content trigger:nil]; + NSError* error = [HelperTools postUserNotificationRequest:request]; + if(error) + DDLogError(@"Error posting new deviceid notification: %@", error); + } + } + + //update own devicelist (this can be an empty list, if the list on our server is empty) + self.ownDeviceList = [receivedDevices mutableCopy]; + //this has to be a property of the xmpp class to persist it even across state resets + self.account.hasSeenOmemoDeviceListAfterOwnDeviceid = YES; + DDLogVerbose(@"Own devicelist for account %@ is now: %@", self.account, self.ownDeviceList); + + //make sure to add our own deviceid to the devicelist if it's not yet there + if(![self.ownDeviceList containsObject:[NSNumber numberWithUnsignedInt:self.monalSignalStore.deviceid]]) + { + [self.ownDeviceList addObject:[NSNumber numberWithUnsignedInt:self.monalSignalStore.deviceid]]; + //generate new prekeys (piggyback the prekey refill onto the bundle push already needed because our device was unknown before) + //publishing our prekey bundle must be done BEFORE publishing a new devicelist containing our deviceid + DDLogDebug(@"Publishing own OMEMO bundle..."); + //in this case (e.g. deviceid unknown) we can't be sure our bundle is saved on the server already + //--> publish bundle even if generateNewKeysIfNeeded did not publish a bundle + if([self generateNewKeysIfNeeded] == NO) + [self sendOMEMOBundle]; + + //publish own devicelist directly after publishing our bundle + [self publishOwnDeviceList]; + } + + [self notifyKnownDevicesUpdated:self.account.connectionProperties.identity.jid]; +} + +-(void) publishOwnDeviceList +{ + DDLogInfo(@"Publishing own OMEMO device list..."); + MLXMLNode* listNode = [[MLXMLNode alloc] initWithElement:@"list" andNamespace:@"eu.siacs.conversations.axolotl"]; + for(NSNumber* deviceNum in self.ownDeviceList) + [listNode addChildNode:[[MLXMLNode alloc] initWithElement:@"device" withAttributes:@{kId: [deviceNum stringValue]} andChildren:@[] andData:nil]]; + [self.account.pubsub publishItem:[[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{kId: @"current"} andChildren:@[ + listNode, + ] andData:nil] onNode:@"eu.siacs.conversations.axolotl.devicelist" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"open" + }]; +} + +-(void) queryOMEMOBundleFrom:(NSString*) jid andDevice:(NSNumber*) deviceid +{ + //don't fetch bundle twice (could be triggered by multiple devicelist pushes in a row) + if(self.state.openBundleFetches[jid] != nil && [self.state.openBundleFetches[jid] containsObject:deviceid]) + { + DDLogInfo(@"Deduplicated bundle fetches of deviceid %@ from %@", jid, deviceid); + return; + } + + //update bundle fetch status + self.openBundleFetchCnt++; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalUpdateBundleFetchStatus object:self userInfo:@{ + @"accountID": self.account.accountID, + @"completed": @(self.closedBundleFetchCnt), + @"all": @(self.openBundleFetchCnt + self.closedBundleFetchCnt) + }]; + + NSString* bundleNode = [NSString stringWithFormat:@"eu.siacs.conversations.axolotl.bundles:%@", deviceid]; + [self.account.pubsub fetchNode:bundleNode from:jid withItemsList:nil andHandler:$newHandlerWithInvalidation(self, handleBundleFetchResult, handleBundleFetchInvalidation, $ID(jid), $ID(rid, deviceid))]; + + if(self.state.openBundleFetches[jid] == nil) + self.state.openBundleFetches[jid] = [NSMutableSet new]; + [self.state.openBundleFetches[jid] addObject:deviceid]; + + [self sendFetchUpdateNotificationForJid:jid]; +} + +//don't mark any devices as deleted in this invalidation handler (like we do for an error in the normal handler below), +//because a timeout could mean a very slow s2s connection and a disconnect will invalidate all handlers, too +//--> we don't want to delete the device in this cases +$$instance_handler(handleBundleFetchInvalidation, account.omemo, $$ID(xmpp*, account), $$ID(NSString*, jid), $$ID(NSNumber*, rid)) + //mark bundle fetch as done + if(self.state.openBundleFetches[jid] != nil && [self.state.openBundleFetches[jid] containsObject:rid]) + [self.state.openBundleFetches[jid] removeObject:rid]; + if(self.state.openBundleFetches[jid] != nil && self.state.openBundleFetches[jid].count == 0) + [self.state.openBundleFetches removeObjectForKey:jid]; + + //update bundle fetch status (this has to be done even in error cases!) + [self decrementBundleFetchCount]; + + //retrigger queued key transport elements for this jid (if any) + [self retriggerKeyTransportElementsForJid:jid]; + + [self sendFetchUpdateNotificationForJid:jid]; +$$ + +$$instance_handler(handleBundleFetchResult, account.omemo, $$ID(xmpp*, account), $$ID(NSString*, jid), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $_ID((NSDictionary*), data), $$ID(NSNumber*, rid)) + //mark bundle fetch as done + if(self.state.openBundleFetches[jid] != nil && [self.state.openBundleFetches[jid] containsObject:rid]) + [self.state.openBundleFetches[jid] removeObject:rid]; + if(self.state.openBundleFetches[jid] != nil && self.state.openBundleFetches[jid].count == 0) + [self.state.openBundleFetches removeObjectForKey:jid]; + + if(!success) + { + if(errorIq) + { + DDLogError(@"Could not fetch bundle from %@: rid: %@ - %@", jid, rid, errorIq); + //delete this device for all non-wait errors + if(![errorIq check:@"error"]) + { + [self handleBundleWithInvalidEntryForJid:jid andRid:rid]; + } + } + //don't delete this device for errorReasons (normally server bugs or transient problems inside monal) + else if(errorReason) + DDLogError(@"Could not fetch bundle from %@: rid: %@ - %@", jid, rid, errorReason); + } + else + { + //check that a corresponding buddy exists -> prevent foreign key errors + MLXMLNode* receivedKeys = data[@"current"]; + if(receivedKeys == nil && data.count == 1) + { + DDLogInfo(@"Client does not use 'current' as item id for it's bundle! rid=%@, keys=%@", rid, [data allKeys]); + //some clients do not use + receivedKeys = [[data allValues] firstObject]; + } + else if(receivedKeys == nil && data.count > 1) + DDLogWarn(@"More than one bundle item found from %@ rid: %@, ignoring all items!", jid, rid); + + if(receivedKeys) + [self processOMEMOKeys:receivedKeys forJid:jid andRid:rid]; + else + { + DDLogWarn(@"Could not find any bundle in pubsub data from %@ rid: %@, data=%@", jid, rid, data); + [self handleBundleWithInvalidEntryForJid:jid andRid:rid]; + } + } + + //update bundle fetch status (this has to be done even in error cases!) + [self decrementBundleFetchCount]; + + //retrigger queued key transport elements for this jid (if any) + [self retriggerKeyTransportElementsForJid:jid]; + + [self sendFetchUpdateNotificationForJid:jid]; +$$ + +-(void) handleBundleWithInvalidEntryForJid:(NSString*) jid andRid:(NSNumber*) rid +{ + SignalAddress* address = [[SignalAddress alloc] initWithName:jid deviceId:rid.unsignedIntValue]; + DDLogInfo(@"Marking device %@ bundle as broken, due to a invalid bundle", rid); + [self.monalSignalStore markBundleAsBroken:address]; + if([jid isEqualToString:self.account.connectionProperties.identity.jid] && rid.unsignedIntValue != self.monalSignalStore.deviceid) + { + DDLogInfo(@"Removing device %@ from own device list, due to a invalid bundle", rid); + [self.monalSignalStore markDeviceAsDeleted:address]; + // removing this device from own bundle + [self.ownDeviceList removeObject:rid]; + // publish updated device list + [self publishOwnDeviceList]; + } +} + +-(BOOL) checkBundleFetchCount +{ + if(self.openBundleFetchCnt == 0 && self.state.catchupDone) + { + //update bundle fetch status (e.g. complete) + self.openBundleFetchCnt = 0; + self.closedBundleFetchCnt = 0; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalFinishedOmemoBundleFetch object:self userInfo:@{ + @"accountID": self.account.accountID, + }]; + return YES; + } + return NO; +} + +-(void) decrementBundleFetchCount +{ + //update bundle fetch status (e.g. pending) + self.openBundleFetchCnt--; + self.closedBundleFetchCnt++; + + //check if we should send a bundle fetch status update or if checkBundleFetchCount already sent the final finished notification for us + if(![self checkBundleFetchCount]) + { + [[MLNotificationQueue currentQueue] postNotificationName:kMonalUpdateBundleFetchStatus object:self userInfo:@{ + @"accountID": self.account.accountID, + @"completed": @(self.closedBundleFetchCnt), + @"all": @(self.openBundleFetchCnt + self.closedBundleFetchCnt), + }]; + } +} + +-(void) processOMEMOKeys:(MLXMLNode*) item forJid:(NSString*) jid andRid:(NSNumber*) rid +{ + MLAssert(self.signalContext != nil, @"self.signalContext must not be nil"); + + //there should only be one bundle per device + //ignore all bundles, if this requirement is not met, to make sure we don't enter some + //strange "omemo loop" with a broken remote software + NSArray* bundles = [item find:@"{eu.siacs.conversations.axolotl}bundle"]; + if([bundles count] != 1) + { + DDLogWarn(@"bundle count != 1, ignoring: %@", bundles); + return; + } + MLXMLNode* bundle = [bundles firstObject]; + + //extract bundle data + NSData* signedPreKeyPublic = [bundle findFirst:@"signedPreKeyPublic#|base64"]; + NSNumber* signedPreKeyPublicId = [bundle findFirst:@"signedPreKeyPublic@signedPreKeyId|uint"]; + NSData* signedPreKeySignature = [bundle findFirst:@"signedPreKeySignature#|base64"]; + NSData* identityKey = [bundle findFirst:@"identityKey#|base64"]; + + //ignore bundles not conforming to the standard + if(signedPreKeyPublic == nil || signedPreKeyPublicId == nil || signedPreKeySignature == nil || identityKey == nil) + { + DDLogWarn(@"Bundle not conforming to omemo standard, ignoring: signedPreKeyPublic=%@, signedPreKeyPublicId=%@, signedPreKeySignature=%@, identityKey=%@", signedPreKeyPublic, signedPreKeyPublicId, signedPreKeySignature, identityKey); + return; + } + + uint32_t deviceId = (uint32_t)rid.unsignedIntValue; + SignalAddress* address = [[SignalAddress alloc] initWithName:jid deviceId:deviceId]; + SignalSessionBuilder* builder = [[SignalSessionBuilder alloc] initWithAddress:address context:self.signalContext]; + NSArray* preKeyIds = [bundle find:@"prekeys/preKeyPublic@preKeyId|uint"]; + + if(preKeyIds == nil || preKeyIds.count == 0) + { + DDLogWarn(@"Could not create array of preKeyIds, ignoring: preKeyIds=%@ %lu", preKeyIds, (unsigned long)preKeyIds.count); + return; + } + + //parse preKeys + unsigned long processedKeys = 0; + do + { + // select random preKey and try to import it + const uint32_t preKeyIdxToTest = arc4random_uniform((uint32_t)preKeyIds.count); + // load preKey + NSNumber* preKeyId = preKeyIds[preKeyIdxToTest]; + if(preKeyId == nil) + continue;; + NSData* key = [bundle findFirst:@"prekeys/preKeyPublic#|base64", preKeyId]; + if(!key) + continue; + + DDLogDebug(@"Generating keyBundle for jid: %@ rid: %u and key id %@...", jid, deviceId, preKeyId); + NSError* error; + SignalPreKeyBundle* keyBundle = [[SignalPreKeyBundle alloc] initWithRegistrationId:0 + deviceId:deviceId + preKeyId:[preKeyId unsignedIntValue] + preKeyPublic:key + signedPreKeyId:signedPreKeyPublicId.unsignedIntValue + signedPreKeyPublic:signedPreKeyPublic + signature:signedPreKeySignature + identityKey:identityKey + error:&error]; + if(error || !keyBundle) + { + DDLogWarn(@"Error creating preKeyBundle: %@", error); + continue; + } + [builder processPreKeyBundle:keyBundle error:&error]; + if(error) + { + DDLogWarn(@"Error adding preKeyBundle: %@", error); + continue; + } + // mark session as functional + SignalAddress* address = [[SignalAddress alloc] initWithName:jid deviceId:(uint32_t)rid.unsignedIntValue]; + [self.monalSignalStore markBundleAsFixed:address]; + + //found and imported a working key --> try to (re)build a new session proactively (or repair a broken one) + [self sendKeyTransportElement:jid forRids:[NSSet setWithArray:@[rid]]]; //this will remove the queuedSessionRepairs entry, if any + + [self notifyKnownDevicesUpdated:jid]; + + return; + } while(++processedKeys < preKeyIds.count); + DDLogError(@"Could not import a single prekey from bundle for rid %@ (tried %lu keys)", rid, processedKeys); + //TODO: should we blacklist this device id? + @synchronized(self.state.queuedSessionRepairs) { + //remove this jid-rid combinations from queuedSessionRepairs + if(self.state.queuedSessionRepairs[jid] != nil) + { + DDLogDebug(@"Removing deviceid %@ on jid %@ from queuedSessionRepairs...", rid, jid); + [self.state.queuedSessionRepairs[jid] removeObject:rid]; + } + } +} + +-(void) rebuildSessionWithJid:(NSString*) jid forRid:(NSNumber*) rid +{ + //don't rebuild session to ourselves (MUST be scoped by jid for omemo 2) + if(rid.unsignedIntValue == self.monalSignalStore.deviceid) + return; + + //mark session as broken + SignalAddress* address = [[SignalAddress alloc] initWithName:jid deviceId:(uint32_t)rid.unsignedIntValue]; + [self.monalSignalStore markSessionAsBroken:address]; + + //queue all actions until the catchup was done + if(!self.state.catchupDone) + { + DDLogDebug(@"Adding deviceid %@ for jid %@ to queuedSessionRepairs...", rid, jid); + @synchronized(self.state.queuedSessionRepairs) { + if(self.state.queuedSessionRepairs[jid] == nil) + self.state.queuedSessionRepairs[jid] = [NSMutableSet new]; + [self.state.queuedSessionRepairs[jid] addObject:rid]; + } + return; + } + + //this will query the bundle and send a key transport element to rebuild the session afterwards + DDLogDebug(@"Trying to repair session with deviceid %@ on jid %@...", rid, jid); + [self queryOMEMOBundleFrom:jid andDevice:rid]; +} + +-(void) sendKeyTransportElement:(NSString*) jid forRids:(NSSet*) rids +{ + //queue all actions until the catchup was done + //OR + //queue all actions until all devicelists and bundles of this jid are fetched + if(!self.state.catchupDone || ([self.state.openDevicelistFetches containsObject:jid] || (self.state.openBundleFetches[jid] != nil && self.state.openBundleFetches[jid].count > 0))) + { + @synchronized(self.state.queuedKeyTransportElements) { + if(self.state.queuedKeyTransportElements[jid] == nil) + self.state.queuedKeyTransportElements[jid] = [NSMutableSet new]; + [self.state.queuedKeyTransportElements[jid] unionSet:rids]; + } + return; + } + + //generate new prekeys if needed and publish them + //this is important to empower the remote device to build a new session for us using prekeys, if needed + [self generateNewKeysIfNeeded]; + + //send key-transport element for all known rids (e.g. devices) to recover broken sessions + //this will remove any queued key transport elements for rids used to encrypt so that we only send one key transport element + DDLogDebug(@"Sending KeyTransportElement to jid: %@", jid); + XMPPMessage* messageNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:jid]; + [self encryptMessage:messageNode withMessage:nil toContact:jid]; + [self.account send:messageNode]; + + @synchronized(self.state.queuedSessionRepairs) { + //remove this jid-rid combinations from queuedSessionRepairs + for(NSNumber* rid in rids) + if(rid != nil && self.state.queuedSessionRepairs[jid] != nil) + { + DDLogDebug(@"Removing deviceid %@ on jid %@ from queuedSessionRepairs...", rid, jid); + [self.state.queuedSessionRepairs[jid] removeObject:rid]; + } + } +} + +-(void) removeQueuedKeyTransportElementsFor:(NSString*) jid andDevices:(NSSet*) devices +{ + @synchronized(self.state.queuedKeyTransportElements) { + if(self.state.queuedKeyTransportElements[jid] != nil) + { + [self.state.queuedKeyTransportElements[jid] minusSet:devices]; + if(self.state.queuedKeyTransportElements[jid].count == 0) + [self.state.queuedKeyTransportElements removeObjectForKey:jid]; + } + } +} + +-(void) sendOMEMOBundle +{ + MLAssert(self.monalSignalStore.deviceid > 0, @"Tried to publish own bundle without knowing my own deviceid!"); + + MLXMLNode* prekeyNode = [[MLXMLNode alloc] initWithElement:@"prekeys"]; + for(SignalPreKey* prekey in [self.monalSignalStore readPreKeys]) + [prekeyNode addChildNode:[[MLXMLNode alloc] initWithElement:@"preKeyPublic" withAttributes:@{ + @"preKeyId": [NSString stringWithFormat:@"%u", prekey.preKeyId], + } andChildren:@[] andData:[HelperTools encodeBase64WithData:prekey.keyPair.publicKey]]]; + + //publish whole bundle via pubsub interface + [self.account.pubsub publishItem:[[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{kId: @"current"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"bundle" andNamespace:@"eu.siacs.conversations.axolotl" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"signedPreKeyPublic" withAttributes:@{ + @"signedPreKeyId": [NSString stringWithFormat:@"%u",self.monalSignalStore.signedPreKey.preKeyId] + } andChildren:@[] andData:[HelperTools encodeBase64WithData:self.monalSignalStore.signedPreKey.keyPair.publicKey]], + [[MLXMLNode alloc] initWithElement:@"signedPreKeySignature" withAttributes:@{} andChildren:@[] andData:[HelperTools encodeBase64WithData:self.monalSignalStore.signedPreKey.signature]], + [[MLXMLNode alloc] initWithElement:@"identityKey" withAttributes:@{} andChildren:@[] andData:[HelperTools encodeBase64WithData:self.monalSignalStore.identityKeyPair.publicKey]], + prekeyNode, + ] andData:nil] + ] andData:nil] onNode:[NSString stringWithFormat:@"eu.siacs.conversations.axolotl.bundles:%u", self.monalSignalStore.deviceid] withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"open" + }]; +} + +/* + * generates new omemo keys if we have less than MIN_OMEMO_KEYS left + * returns YES if keys were generated and the new omemo bundle was send + */ +-(BOOL) generateNewKeysIfNeeded +{ + // generate new keys if less than MIN_OMEMO_KEYS are available + unsigned int preKeyCount = [self.monalSignalStore getPreKeyCount]; + if(preKeyCount < MIN_OMEMO_KEYS) + { + SignalKeyHelper* signalHelper = [[SignalKeyHelper alloc] initWithContext:self.signalContext]; + + // Generate new keys so that we have a total of MAX_OMEMO_KEYS keys again + int lastPreyKedId = [self.monalSignalStore getHighestPreyKeyId]; + if(MAX_OMEMO_KEYS < preKeyCount) + { + DDLogWarn(@"OMEMO MAX_OMEMO_KEYs has changed: MAX: %zu current: %u", MAX_OMEMO_KEYS, preKeyCount); + return NO; + } + size_t cntKeysNeeded = MAX_OMEMO_KEYS - preKeyCount; + if(cntKeysNeeded == 0) + { + DDLogWarn(@"No new prekeys needed"); + return NO; + } + // Start generating with keyId > last send key id + self.monalSignalStore.preKeys = [signalHelper generatePreKeysWithStartingPreKeyId:(lastPreyKedId + 1) count:cntKeysNeeded]; + [self.monalSignalStore saveValues]; + + // send out new omemo bundle + [self sendOMEMOBundle]; + return YES; + } + return NO; +} + +-(MLXMLNode* _Nullable) encryptString:(NSString* _Nullable) message toDeviceids:(NSDictionary*>*) contactDeviceMap +{ + + MLXMLNode* encrypted = [[MLXMLNode alloc] initWithElement:@"encrypted" andNamespace:@"eu.siacs.conversations.axolotl"]; + + MLEncryptedPayload* encryptedPayload; + if(message) + { + // Encrypt message + encryptedPayload = [AESGcm encrypt:[message dataUsingEncoding:NSUTF8StringEncoding] keySize:KEY_SIZE]; + if(encryptedPayload == nil) + { + showErrorOnAlpha(self.account, @"Could not encrypt normal message: AESGcm error"); + return nil; + } + [encrypted addChildNode:[[MLXMLNode alloc] initWithElement:@"payload" andData:[HelperTools encodeBase64WithData:encryptedPayload.body]]]; + } + else + { + //there is no message that can be encrypted -> create new session keys (e.g. this is a key transport message) + NSData* newKey = [AESGcm genKey:KEY_SIZE]; + NSData* newIv = [AESGcm genIV]; + if(newKey == nil || newIv == nil) + { + showErrorOnAlpha(self.account, @"Could not create key or iv"); + return nil; + } + encryptedPayload = [[MLEncryptedPayload alloc] initWithKey:newKey iv:newIv]; + if(encryptedPayload == nil) + { + showErrorOnAlpha(self.account, @"Could not encrypt transport message: AESGcm error"); + return nil; + } + } + + //add crypto header with our own deviceid + MLXMLNode* header = [[MLXMLNode alloc] initWithElement:@"header" withAttributes:@{ + @"sid": [NSString stringWithFormat:@"%u", self.monalSignalStore.deviceid], + } andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"iv" andData:[HelperTools encodeBase64WithData:encryptedPayload.iv]], + ] andData:nil]; + + //add encryption for all given contacts' devices + for(NSString* recipient in contactDeviceMap) + { + DDLogVerbose(@"Adding encryption for devices of %@: %@", recipient, contactDeviceMap[recipient]); + [self addEncryptionKeyForAllDevices:contactDeviceMap[recipient] encryptForJid:recipient withEncryptedPayload:encryptedPayload withXMLHeader:header]; + } + + [encrypted addChildNode:header]; + return encrypted; +} + +-(void) encryptMessage:(XMPPMessage*) messageNode withMessage:(NSString* _Nullable) message toContact:(NSString*) toContact +{ + MLAssert(self.signalContext != nil, @"signalContext should be initiated."); + + //add xmpp message fallback body (needed to make clear that this is not a key transport message) + //don't remove this, message contains the cleartext message! + if(message) + [messageNode setBody:@"[This message is OMEMO encrypted]"]; + else + { + //KeyTransportElements don't contain a body --> force storage to MAM nonetheless + [messageNode setStoreHint]; + } + + NSMutableSet* recipients = [NSMutableSet new]; + if([[DataLayer sharedInstance] isBuddyMuc:toContact forAccount:self.account.accountID]) + for(NSDictionary* participant in [[DataLayer sharedInstance] getMembersAndParticipantsOfMuc:toContact forAccountID:self.account.accountID]) + { + if(participant[@"participant_jid"]) + [recipients addObject:participant[@"participant_jid"]]; + else if(participant[@"member_jid"]) + [recipients addObject:participant[@"member_jid"]]; + } + else + [recipients addObject:toContact]; + + //remove own jid from recipients (our own devices get special treatment via myDevices NSSet below) + [recipients removeObject:self.account.connectionProperties.identity.jid]; + + NSMutableDictionary*>* contactDeviceMap = [NSMutableDictionary new]; + for(NSString* recipient in recipients) + { + //contactDeviceMap + NSMutableSet* recipientDevices = [NSMutableSet new]; + [recipientDevices addObjectsFromArray:[self.monalSignalStore knownDevicesWithValidSession:recipient]]; + // add devices with known but old broken session to trigger a bundle refetch + [recipientDevices addObjectsFromArray:[self.monalSignalStore knownDevicesWithPendingBrokenSessionHandling:recipient]]; + + if(recipientDevices && recipientDevices.count > 0) + contactDeviceMap[recipient] = recipientDevices; + } + + //check if we found omemo keys of at least one of the recipients or more than 1 own device, otherwise don't encrypt anything + NSSet* myDevices = [self knownDevicesForAddressName:self.account.connectionProperties.identity.jid]; + if(contactDeviceMap.count > 0 || myDevices.count > 1) + { + //add encryption for all of our own devices to contactDeviceMap + DDLogVerbose(@"Adding encryption for OWN (%@) devices to contactDeviceMap: %@", self.account.connectionProperties.identity.jid, myDevices); + contactDeviceMap[self.account.connectionProperties.identity.jid] = myDevices; + + //now encrypt everything to all collected deviceids + MLXMLNode* envelope = [self encryptString:message toDeviceids:contactDeviceMap]; + if(envelope == nil) + { + DDLogError(@"Got nil envelope!"); + return; + } + [messageNode addChildNode:envelope]; + } +} + +-(NSNumber* _Nullable) getTrustLevelForJid:(NSString*) jid andDeviceId:(NSNumber*) deviceid +{ + SignalAddress* address = [[SignalAddress alloc] initWithName:jid deviceId:(uint32_t)deviceid.unsignedIntValue]; + NSData* identity = [self.monalSignalStore getIdentityForAddress:address]; + if(!identity) + { + showErrorOnAlpha(self.account, @"Could not get Identity for: %@ device id %@", jid, deviceid); + return nil; + } + return [self getTrustLevel:address identityKey:identity]; +} + +-(void) addEncryptionKeyForAllDevices:(NSSet*) devices encryptForJid:(NSString*) encryptForJid withEncryptedPayload:(MLEncryptedPayload*) encryptedPayload withXMLHeader:(MLXMLNode*) xmlHeader +{ + NSMutableSet* usedRids = [NSMutableSet new]; + //encrypt message for all given deviceids + for(NSNumber* device in devices) + { + //do not encrypt for our own device (MUST be scoped by jid for omemo 2) + if(device.unsignedIntValue == self.monalSignalStore.deviceid) + continue; + + if(self.state.openBundleFetches[encryptForJid] != nil && [self.state.openBundleFetches[encryptForJid] containsObject:device]) + { + DDLogWarn(@"Ignoring deviceid %@ of %@ for KeyTransportElement: bundle fetch still pending...", device, encryptForJid); + continue; + } + + SignalAddress* address = [[SignalAddress alloc] initWithName:encryptForJid deviceId:(uint32_t)device.unsignedIntValue]; + + NSData* identity = [self.monalSignalStore getIdentityForAddress:address]; + if(!identity) + { + showErrorOnAlpha(self.account, @"Could not get Identity for: %@ device id %@", encryptForJid, device); + //TODO: is it correct to rebuild broken(?) session here, too? + [self rebuildSessionWithJid:encryptForJid forRid:device]; + continue; + } + //only encrypt for devices that are trusted (tofu or explicitly) + if([self.monalSignalStore isTrustedIdentity:address identityKey:identity]) + { + SignalSessionCipher* cipher = [[SignalSessionCipher alloc] initWithAddress:address context:self.signalContext]; + NSError* error; + SignalCiphertext* deviceEncryptedKey = [cipher encryptData:encryptedPayload.key error:&error]; + if(error) + { + //only show errors not being of type "unknown error" + if(![error.domain isEqualToString:@"org.whispersystems.SignalProtocol"] || error.code != 0) + showErrorOnAlpha(self.account, @"Error while adding encryption key for jid: %@ device: %@ error: %@", encryptForJid, device, error); + [self rebuildSessionWithJid:encryptForJid forRid:device]; + continue; + } + [xmlHeader addChildNode:[[MLXMLNode alloc] initWithElement:@"key" withAttributes:@{ + @"rid": [NSString stringWithFormat:@"%@", device], + @"prekey": (deviceEncryptedKey.type == SignalCiphertextTypePreKeyMessage ? @"1" : @"0"), + } andChildren:@[] andData:[HelperTools encodeBase64WithData:deviceEncryptedKey.data]]]; + + //record this deviceid as used for encryption (it doesn't need any further key transport element potentially already queued) + [usedRids addObject:device]; + } + } + + //remove queued key transport element entry + [self removeQueuedKeyTransportElementsFor:encryptForJid andDevices:usedRids]; +} + +-(NSString* _Nullable) decryptOmemoEnvelope:(MLXMLNode*) envelope forSenderJid:(NSString*) senderJid andReturnErrorString:(BOOL) returnErrorString +{ + DDLogVerbose(@"OMEMO envelope: %@", envelope); + + if(![envelope check:@"header"]) + { + showErrorOnAlpha(self.account, @"decryptOmemoEnvelope called but the envelope has no encryption header"); + return nil; + } + + BOOL isKeyTransportElement = ![envelope check:@"payload"]; + NSNumber* sid = [envelope findFirst:@"header@sid|uint"]; + + SignalAddress* address = [[SignalAddress alloc] initWithName:senderJid deviceId:(uint32_t)sid.unsignedIntValue]; + + if(!self.signalContext) + { + showErrorOnAlpha(self.account, @"Missing signal context in decrypt!"); + return !returnErrorString ? nil : NSLocalizedString(@"Error decrypting message", @""); + } + + //don't try to decrypt our own messages (could be mirrored by MUC etc.) + if([senderJid isEqualToString:self.account.connectionProperties.identity.jid] && sid.unsignedIntValue == self.monalSignalStore.deviceid) + return nil; + + NSData* messageKey = [envelope findFirst:@"header/key#|base64", self.monalSignalStore.deviceid]; + BOOL devicePreKey = [[envelope findFirst:@"header/key@prekey|bool", self.monalSignalStore.deviceid] boolValue]; + + DDLogVerbose(@"Decrypting using:\nrid=%u --> messageKey=%@\nrid=%u --> isPreKey=%@", self.monalSignalStore.deviceid, messageKey, self.monalSignalStore.deviceid, bool2str(devicePreKey)); + + if(!messageKey && isKeyTransportElement) + { + DDLogVerbose(@"Received KeyTransportElement without our own rid included --> Ignore it"); + return nil; + } + else if(!messageKey) + { + DDLogError(@"Message was not encrypted for this device: %u", self.monalSignalStore.deviceid); + [self rebuildSessionWithJid:senderJid forRid:sid]; + return !returnErrorString ? nil : [NSString stringWithFormat:NSLocalizedString(@"Message was not encrypted for this device. Please make sure the sender trusts deviceid %u.", @""), self.monalSignalStore.deviceid]; + } + else + { + SignalSessionCipher* cipher = [[SignalSessionCipher alloc] initWithAddress:address context:self.signalContext]; + SignalCiphertextType messagetype; + + //check if message is encrypted with a prekey + if(devicePreKey) + messagetype = SignalCiphertextTypePreKeyMessage; + else + messagetype = SignalCiphertextTypeMessage; + + NSData* decoded = messageKey; + + SignalCiphertext* ciphertext = [[SignalCiphertext alloc] initWithData:decoded type:messagetype]; + NSError* error; + NSData* decryptedKey = [cipher decryptCiphertext:ciphertext error:&error]; + if(error != nil) + { + DDLogError(@"Could not decrypt to obtain key: %@", error); + //don't report error or try to rebuild session, if this was just a duplicated message + if([@"org.whispersystems.SignalProtocol" isEqualToString:error.domain] && error.code == 3) + { + DDLogDebug(@"Deduplicated %@ message via omemo...", isKeyTransportElement ? @"key transport" : @"normal"); + return nil; + } + [self rebuildSessionWithJid:senderJid forRid:sid]; +#ifdef IS_ALPHA + if(isKeyTransportElement) + return !returnErrorString ? nil : [NSString stringWithFormat:@"There was an error decrypting this encrypted KEY TRANSPORT message (Signal error). To resolve this, try sending an encrypted message to this person. (%@)", error]; +#endif + if(!isKeyTransportElement) + return !returnErrorString ? nil : [NSString stringWithFormat:NSLocalizedString(@"There was an error decrypting this encrypted message (Signal error). To resolve this, try sending an encrypted message to this person. (%@)", @""), error]; + return nil; + } + NSData* key; + NSData* auth; + + if(decryptedKey == nil) + { + DDLogError(@"Could not decrypt to obtain key (returned nil)"); + [self rebuildSessionWithJid:senderJid forRid:sid]; +#ifdef IS_ALPHA + if(isKeyTransportElement) + return !returnErrorString ? nil : @"There was an error decrypting this encrypted KEY TRANSPORT message (Signal error). To resolve this, try sending an encrypted message to this person."; +#endif + if(!isKeyTransportElement) + return !returnErrorString ? nil : NSLocalizedString(@"There was an error decrypting this encrypted message (Signal error). To resolve this, try sending an encrypted message to this person.", @""); + return nil; + } + else + { + if(messagetype == SignalCiphertextTypePreKeyMessage) + { + //(re)build session + [self sendKeyTransportElement:senderJid forRids:[NSSet setWithArray:@[sid]]]; + } + + //save last successfull decryption time and remove possibly queued session repair + [self.monalSignalStore updateLastSuccessfulDecryptTime:address]; + @synchronized(self.state.queuedSessionRepairs) { + if(self.state.queuedSessionRepairs[senderJid] != nil) + { + DDLogDebug(@"Removing deviceid %@ on jid %@ from queuedSessionRepairs (we successfully decrypted a message)...", sid, senderJid); + [self.state.queuedSessionRepairs[senderJid] removeObject:sid]; + } + } + + //key transport elements have an empty payload --> nothing to return as decrypted + if(isKeyTransportElement) + { + DDLogInfo(@"KeyTransportElement received from jid: %@ device: %@", senderJid, sid); +#ifdef IS_ALPHA + return !returnErrorString ? nil : [NSString stringWithFormat:@"ALPHA_DEBUG_MESSAGE: KeyTransportElement received from jid: %@ device: %@", senderJid, sid]; +#else + return nil; +#endif + } + + //some clients have the auth parameter in the ciphertext? + if(decryptedKey.length == 16 * 2) + { + key = [decryptedKey subdataWithRange:NSMakeRange(0, 16)]; + auth = [decryptedKey subdataWithRange:NSMakeRange(16, 16)]; + } + else + key = decryptedKey; + + if(key != nil) + { + NSData* iv = [envelope findFirst:@"header/iv#|base64"]; + NSData* decodedPayload = [envelope findFirst:@"payload#|base64"]; + if(iv == nil || iv.length != 12) + { + showErrorOnAlpha(self.account, @"Could not decrypt message: iv length: %lu", (unsigned long)iv.length); + return !returnErrorString ? nil : NSLocalizedString(@"Error while decrypting: iv.length != 12", @""); + } + if(decodedPayload == nil) + { + return !returnErrorString ? nil : NSLocalizedString(@"Error: Received OMEMO message is empty", @""); + } + + NSData* decData = [AESGcm decrypt:decodedPayload withKey:key andIv:iv withAuth:auth]; + if(decData == nil) + { + showErrorOnAlpha(self.account, @"Could not decrypt message with key that was decrypted. (GCM error)"); + return !returnErrorString ? nil : NSLocalizedString(@"Encrypted message was sent in an older format Monal can't decrypt. Please ask them to update their client. (GCM error)", @""); + } + else + DDLogInfo(@"Successfully decrypted message, passing back cleartext string..."); + return [[NSString alloc] initWithData:decData encoding:NSUTF8StringEncoding]; + } + else + { + showErrorOnAlpha(self.account, @"Could not get omemo decryption key"); + return !returnErrorString ? nil : NSLocalizedString(@"Could not decrypt message", @""); + } + } + } +} + +-(NSString* _Nullable) decryptMessage:(XMPPMessage*) messageNode withMucParticipantJid:(NSString* _Nullable) mucParticipantJid +{ + NSString* senderJid = nil; + if([messageNode check:@"/"]) + { + if(mucParticipantJid == nil) + { + DDLogError(@"Could not get muc participant jid and corresponding signal address of muc participant '%@': %@", messageNode.from, mucParticipantJid); +#ifdef IS_ALPHA + return [NSString stringWithFormat:@"Could not get muc participant jid and corresponding signal address of muc participant '%@': %@", messageNode.from, mucParticipantJid]; +#else + return nil; +#endif + } + else + senderJid = mucParticipantJid; + } + else + senderJid = messageNode.fromUser; + + return [self decryptOmemoEnvelope:[messageNode findFirst:@"{eu.siacs.conversations.axolotl}encrypted"] forSenderJid:senderJid andReturnErrorString:YES]; +} + +$$instance_handler(handleDevicelistUnsubscribe, account.omemo, $$ID(xmpp*, account), $$ID(NSString*, jid), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + if(success == NO) + { + if(errorIq) + DDLogError(@"Error while unsubscribing omemo deviceslist from: %@ - %@", jid, errorIq); + else + DDLogError(@"Error while unsubscribing omemo deviceslist from: %@ - %@", jid, errorReason); + } + // TODO: improve error handling +$$ + +//called after new contact was added via roster or a new MUC member was added by MLMucProcessor +-(void) subscribeAndFetchDevicelistIfNoSessionExistsForJid:(NSString*) buddyJid +{ + if([self.monalSignalStore sessionsExistForBuddy:buddyJid] == NO) + { + DDLogVerbose(@"No omemo session for %@", buddyJid); + MLContact* contact = [MLContact createContactFromJid:buddyJid andAccountID:self.account.accountID]; + //only subscribe if we don't receive automatic headline pushes of the devicelist + DDLogVerbose(@"Fetching devicelist %@ from contact: %@", !contact.isSubscribedTo ? @"with subscribe" : @"without subscribe", contact); + [self queryOMEMODevices:buddyJid withSubscribe:!contact.isSubscribedTo]; + } + else + { + //make sure we don't show the omemo key fetching hud forever + [self sendFetchUpdateNotificationForJid:buddyJid]; + } +} + +//called after a buddy was deleted from roster OR by MLMucProcessor after a MUC member was removed +-(void) checkIfSessionIsStillNeeded:(NSString*) buddyJid isMuc:(BOOL) isMuc +{ + NSMutableSet* danglingJids = [NSMutableSet new]; + if(isMuc == YES) + danglingJids = [[NSMutableSet alloc] initWithSet:[self.monalSignalStore removeDanglingMucSessions]]; + else if([self.monalSignalStore checkIfSessionIsStillNeeded:buddyJid] == NO) + [danglingJids addObject:buddyJid]; + + DDLogVerbose(@"Unsubscribing from dangling jids: %@", danglingJids); + for(NSString* jid in danglingJids) + [self.account.pubsub unsubscribeFromNode:@"eu.siacs.conversations.axolotl.devicelist" forJid:jid withHandler:$newHandler(self, handleDevicelistUnsubscribe)]; + + [self notifyKnownDevicesUpdated:buddyJid]; +} + +//interfaces for UI +-(BOOL) isTrustedIdentity:(SignalAddress*) address identityKey:(NSData*) identityKey +{ + return [self.monalSignalStore isTrustedIdentity:address identityKey:identityKey]; +} + +-(NSNumber*) getTrustLevel:(SignalAddress*) address identityKey:(NSData*) identityKey +{ + return [self.monalSignalStore getTrustLevel:address identityKey:identityKey]; +} + +// add OMEMO identity manually to our signalstore +// only intended to be called from OMEMO QR scan UI +-(void) addIdentityManually:(SignalAddress*) address identityKey:(NSData* _Nonnull) identityKey +{ + [self.monalSignalStore saveIdentity:address identityKey:identityKey]; + [self notifyKnownDevicesUpdated:address.name]; +} + +-(void) updateTrust:(BOOL) trust forAddress:(SignalAddress*)address +{ + [self.monalSignalStore updateTrust:trust forAddress:address]; +} + +-(void) untrustAllDevicesFrom:(NSString*) jid +{ + [self.monalSignalStore untrustAllDevicesFrom:jid]; +} + +-(NSData*) getIdentityForAddress:(SignalAddress*) address +{ + return [self.monalSignalStore getIdentityForAddress:address]; +} + +-(BOOL) isSessionBrokenForJid:(NSString*) jid andDeviceId:(NSNumber*) rid +{ + return [self.monalSignalStore isSessionBrokenForJid:jid andDeviceId:rid]; +} + +-(NSNumber*) getDeviceId +{ + return [NSNumber numberWithUnsignedInt:self.monalSignalStore.deviceid]; +} + +-(void) deleteDeviceForSource:(NSString*) source andRid:(NSNumber*) rid +{ + //we should not delete our own device + if([source isEqualToString:self.account.connectionProperties.identity.jid] && rid.unsignedIntValue == self.monalSignalStore.deviceid) + return; + //handle removal of own deviceids + if([source isEqualToString:self.account.connectionProperties.identity.jid]) + { + [self.ownDeviceList removeObject:rid]; + [self publishOwnDeviceList]; + } + + SignalAddress* address = [[SignalAddress alloc] initWithName:source deviceId:rid.unsignedIntValue]; + [self.monalSignalStore deleteDeviceforAddress:address]; + [self.monalSignalStore deleteSessionRecordForAddress:address]; + [self notifyKnownDevicesUpdated:address.name]; +} + +//debug button in contactdetails ui +-(void) clearAllSessionsForJid:(NSString*) jid +{ + NSSet* devices = [self knownDevicesForAddressName:jid]; + for(NSNumber* device in devices) + { + [self deleteDeviceForSource:jid andRid:device]; + } + [self sendOMEMOBundle]; + [self.account.pubsub fetchNode:@"eu.siacs.conversations.axolotl.devicelist" from:self.account.connectionProperties.identity.jid withItemsList:nil andHandler:$newHandlerWithInvalidation(self, handleDevicelistFetch, handleDevicelistFetchInvalidation, $BOOL(subscribe, NO))]; + [self.account.pubsub fetchNode:@"eu.siacs.conversations.axolotl.devicelist" from:jid withItemsList:nil andHandler:$newHandlerWithInvalidation(self, handleDevicelistFetch, handleDevicelistFetchInvalidation, $BOOL(subscribe, NO))]; +} + +-(void) sendFetchUpdateNotificationForJid:(NSString*) jid +{ + BOOL isFetching = self.state.openBundleFetches[jid] != nil || [self.state.openDevicelistFetches containsObject:jid] || [self.state.openDevicelistSubscriptions containsObject:jid]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalOmemoFetchingStateUpdate object:self.account userInfo:@{ + @"jid": jid, + @"isFetching": @(isFetching), + @"fetchingBundle": @(self.state.openBundleFetches[jid] != nil), + @"fetchingDevicelist": @([self.state.openDevicelistFetches containsObject:jid]), + @"subscribingDevicelist": @([self.state.openDevicelistSubscriptions containsObject:jid]), + }]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLOgHtmlParser.swift b/Monal/Classes/MLOgHtmlParser.swift new file mode 100644 index 0000000..876bd57 --- /dev/null +++ b/Monal/Classes/MLOgHtmlParser.swift @@ -0,0 +1,54 @@ +// +// ogHtmlParser.swift +// Monal +// +// Created by Friedrich Altheide on 27.06.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +@objc class MLOgHtmlParser: NSObject { + var og_title: String? + var og_image_url: URL? + + @objc init(html: String, andBaseUrl baseUrl: URL?) { + super.init() + let parsedSite = HtmlParserBridge(html:html) + + self.og_title = try? parsedSite.select("meta[property=og\\:title]", attribute:"content").first + if self.og_title == nil { + self.og_title = try? parsedSite.select("html head title").first + } + if self.og_title == nil { + DDLogWarn("Could not find any site title") + } + + if let image_url = try? parsedSite.select("meta[property=og\\:image]", attribute:"content").first?.removingPercentEncoding { + self.og_image_url = self.parseUrl(image_url, baseUrl) + } else if let image_url = try? parsedSite.select("html head link[rel=apple-touch-icon]", attribute:"href").first?.removingPercentEncoding { + self.og_image_url = self.parseUrl(image_url, baseUrl) + } else if let image_url = try? parsedSite.select("html head link[rel=icon]", attribute:"href").first?.removingPercentEncoding { + self.og_image_url = self.parseUrl(image_url, baseUrl) + } else if let image_url = try? parsedSite.select("html head link[rel=shortcut icon]", attribute:"href").first?.removingPercentEncoding { + self.og_image_url = self.parseUrl(image_url, baseUrl) + } else { + DDLogWarn("Could not find any site image in html") + } + } + + private func parseUrl(_ url: String, _ baseUrl: URL?) -> URL? { + if url.hasPrefix("http") { + return URL.init(string:url)?.absoluteURL + } else if let baseUrl = baseUrl { + return URL.init(string:url, relativeTo:baseUrl)?.absoluteURL + } + return nil + } + + @objc func getOgTitle() -> String? { + self.og_title + } + + @objc func getOgImage() -> URL? { + self.og_image_url + } +} diff --git a/Monal/Classes/MLPasswordChangeTableViewController.h b/Monal/Classes/MLPasswordChangeTableViewController.h new file mode 100644 index 0000000..ed5fe24 --- /dev/null +++ b/Monal/Classes/MLPasswordChangeTableViewController.h @@ -0,0 +1,25 @@ +// +// MLPasswordChangeTableViewController.h +// Monal +// +// Created by Anurodh Pokharel on 5/22/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" +#import "xmpp.h" +#import "MLButtonCell.h" +#import "MLTextInputCell.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLPasswordChangeTableViewController : UITableViewController + +@property (nonatomic, strong) xmpp *xmppAccount; +-(IBAction) changePress:(id)sender; + +@end + + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLPasswordChangeTableViewController.m b/Monal/Classes/MLPasswordChangeTableViewController.m new file mode 100644 index 0000000..0460b2d --- /dev/null +++ b/Monal/Classes/MLPasswordChangeTableViewController.m @@ -0,0 +1,200 @@ +// +// MLPasswordChangeTableViewController.m +// Monal +// +// Created by Anurodh Pokharel on 5/22/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLPasswordChangeTableViewController.h" +#import "MBProgressHUD.h" +#import "MLXMPPManager.h" + + +@interface MLPasswordChangeTableViewController () +@property (nonatomic, weak) MLTextInputCell* passwordOld; +@property (nonatomic, weak) MLTextInputCell* passwordNew; +@property (nonatomic, strong) MBProgressHUD* progress; +@end + +@implementation MLPasswordChangeTableViewController + +-(void) closeView +{ + [self dismissViewControllerAnimated:YES completion:nil]; +} + +-(IBAction) changePress:(id)sender +{ + if(!self.xmppAccount) + { + UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"No connected accounts", @"") message:NSLocalizedString(@"Please make sure you are connected before changing your password.", @"") preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* closeAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) {}]; + [messageAlert addAction:closeAction]; + + [self presentViewController:messageAlert animated:YES completion:nil]; + } + else + { + if([self.passwordNew getText].length > 0 && [self.passwordOld getText] > 0) + { + if([[MLXMPPManager sharedInstance] isValidPassword:[self.passwordOld getText] forAccount:self.xmppAccount.accountID] == NO) + { + UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Invalid Password!", @"") message:NSLocalizedString(@"The current password is not correct.", @"") preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* closeAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) {}]; + [messageAlert addAction:closeAction]; + + [self presentViewController:messageAlert animated:YES completion:nil]; + return; + } + + self.progress = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + self.progress.label.text = NSLocalizedString(@"Changing Password", @""); + self.progress.mode = MBProgressHUDModeIndeterminate; + self.progress.removeFromSuperViewOnHide = YES; + self.progress.hidden = NO; + + [self.xmppAccount changePassword:[self.passwordNew getText] withCompletion:^(BOOL success, NSString* message) { + dispatch_async(dispatch_get_main_queue(), ^{ + self.progress.hidden = YES; + NSString* title = NSLocalizedString(@"Error", @""); + NSString* displayMessage = message; + if(success == YES) { + title = NSLocalizedString(@"Success", @""); + displayMessage = NSLocalizedString(@"The password has been changed", @""); + + [[MLXMPPManager sharedInstance] updatePassword:[self.passwordNew getText] forAccount:self.xmppAccount.accountID]; + } else { + if(displayMessage.length == 0) displayMessage = NSLocalizedString(@"Could not change the password", @""); + } + + UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:title message:displayMessage preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* closeAction =[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + + }]; + [messageAlert addAction:closeAction]; + + [self presentViewController:messageAlert animated:YES completion:nil]; + }); + }]; + } + else + { + UIAlertController *messageAlert =[UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", @"") message:NSLocalizedString(@"Password cannot be empty", @"") preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* closeAction =[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + + }]; + [messageAlert addAction:closeAction]; + + [self presentViewController:messageAlert animated:YES completion:nil]; + + } + + } +} + +#pragma mark - textfield delegate + +- (BOOL)textFieldShouldReturn:(UITextField *)textField +{ + [textField resignFirstResponder]; + + return YES; +} + + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField { + + return YES; +} + + +#pragma mark View life cycle + +-(void) viewDidLoad +{ + [super viewDidLoad]; + self.navigationItem.title=NSLocalizedString(@"Change Password", @""); + [self.tableView registerNib:[UINib nibWithNibName:@"MLTextInputCell" + bundle:[NSBundle mainBundle]] + forCellReuseIdentifier:@"TextCell"]; +} + +-(void) viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + +} + +#pragma mark tableview datasource delegate + +-(NSInteger) numberOfSectionsInTableView:(UITableView *)tableView +{ + return 2; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + if(section == 0) + return NSLocalizedString(@"Enter your new password. Passwords may not be empty. They may also be governed by server or company policies.",@ ""); + else + return nil; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + NSInteger toreturn = 0; + switch (section) { + case 0: + toreturn = 2; + break; + case 1: + toreturn = 1; + break; + + default: + break; + } + + return toreturn; +} + +- (UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath +{ + if(indexPath.section == 0) + { + MLTextInputCell* textCell = [tableView dequeueReusableCellWithIdentifier:@"TextCell"]; + if(indexPath.row == 0) + { +#ifdef IS_QUICKSY + [textCell initTextCell:[[MLXMPPManager sharedInstance] getPasswordForAccount:self.xmppAccount.accountID] andPlaceholder:NSLocalizedString(@"Current Password", @"") andDelegate:self]; +#else + [textCell initPasswordCell:[[MLXMPPManager sharedInstance] getPasswordForAccount:self.xmppAccount.accountID] andPlaceholder:NSLocalizedString(@"Current Password", @"") andDelegate:self]; +#endif + self.passwordOld = textCell; + } + else if(indexPath.row == 1) + { + [textCell initPasswordCell:nil andPlaceholder:NSLocalizedString(@"New Password", @"") andDelegate:self]; + self.passwordNew = textCell; + } + else + { + unreachable(); + } + return textCell; + } + else + return [tableView dequeueReusableCellWithIdentifier:@"addButton"]; +} + +#pragma mark tableview delegate +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + +} + + + + +@end diff --git a/Monal/Classes/MLPipe.h b/Monal/Classes/MLPipe.h new file mode 100755 index 0000000..95a9631 --- /dev/null +++ b/Monal/Classes/MLPipe.h @@ -0,0 +1,23 @@ +// +// MLPipe.h +// Monal +// +// Created by Thilo Molitor on 03.05.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLPipe : NSObject + +-(id) initWithInputStream:(NSInputStream*) inputStream andOuterDelegate:(id ) outerDelegate; +-(void) close; +-(NSInputStream*) getNewOutputStream; +-(NSNumber*) drainInputStreamAndCloseOutputStream; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLPipe.m b/Monal/Classes/MLPipe.m new file mode 100755 index 0000000..97e7ae3 --- /dev/null +++ b/Monal/Classes/MLPipe.m @@ -0,0 +1,286 @@ +// +// MLPipe.m +// Monal +// +// Created by Thilo Molitor on 03.05.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLPipe.h" +#import "HelperTools.h" + +#define kPipeBufferSize 4096 + +@interface MLPipe() +{ + uint8_t _staticOutputBuffer[kPipeBufferSize+1]; //+1 for '\0' needed for logging the received raw bytes + + //buffer for writes to the output stream that can not be completed + uint8_t* _outputBuffer; + size_t _outputBufferByteCount; +} + +@property (atomic, strong) NSInputStream* input; +@property (atomic, strong) NSOutputStream* output; +@property (assign) id delegate; + +@end + +@implementation MLPipe + +-(id) initWithInputStream:(NSInputStream*) inputStream andOuterDelegate:(id) outerDelegate +{ + _input = inputStream; + _delegate = outerDelegate; + _outputBufferByteCount = 0; + [_input setDelegate:self]; + [_input scheduleInRunLoop:[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierNetwork] forMode:NSDefaultRunLoopMode]; + return self; +} + +-(void) dealloc +{ + DDLogInfo(@"Deallocating pipe"); + [self close]; +} + +-(void) close +{ + @synchronized(self) { + //check if the streams are already closed + if(!_input && !_output) + return; + DDLogInfo(@"Closing pipe"); + [self cleanupOutputBuffer]; + @try + { + if(_input) + { + DDLogInfo(@"Closing pipe: input end"); + [_input setDelegate:nil]; + [_input removeFromRunLoop:[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierNetwork] forMode:NSDefaultRunLoopMode]; + [_input close]; + _input = nil; + } + if(_output) + { + DDLogInfo(@"Closing pipe: output end"); + [_output setDelegate:nil]; + [_output removeFromRunLoop:[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierNetwork] forMode:NSDefaultRunLoopMode]; + [_output close]; + _output = nil; + } + DDLogInfo(@"Pipe closed"); + } + @catch(id theException) + { + DDLogError(@"Exception while closing pipe: %@", theException); + } + } +} + +-(NSInputStream*) getNewOutputStream +{ + @synchronized(self) { + //make current output stream orphan + if(_output) + { + DDLogInfo(@"Pipe making output stream orphan"); + [_output setDelegate:nil]; + [_output removeFromRunLoop:[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierNetwork] forMode:NSDefaultRunLoopMode]; + [_output close]; + _output = nil; + } + [self cleanupOutputBuffer]; + + //create new stream pair and schedule it properly, see: https://stackoverflow.com/a/31961573/3528174 + DDLogInfo(@"Pipe creating new stream pair"); + CFReadStreamRef readStream = NULL; + CFWriteStreamRef writeStream = NULL; + CFStreamCreateBoundPair(NULL, &readStream, &writeStream, kPipeBufferSize); + NSInputStream* inputStream = (__bridge_transfer NSInputStream *)readStream; + _output = (__bridge_transfer NSOutputStream *)writeStream; + [_output setDelegate:self]; + [_output scheduleInRunLoop:[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierNetwork] forMode:NSDefaultRunLoopMode]; + [_output open]; + [inputStream open]; + return inputStream; + } +} + +-(NSNumber*) drainInputStreamAndCloseOutputStream +{ + @synchronized(self) { + //make current output stream orphan + if(_output) + { + DDLogInfo(@"Pipe making output stream orphan"); + [_output setDelegate:nil]; + [_output removeFromRunLoop:[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierNetwork] forMode:NSDefaultRunLoopMode]; + [_output close]; + _output = nil; + } + [self cleanupOutputBuffer]; + + NSInteger drainedBytes = 0; + NSInteger len = 0; + do + { + if(![_input hasBytesAvailable]) + break; + //read bytes but don't increment _outputBufferByteCount (e.g. ignore these bytes) + len = [_input read:_staticOutputBuffer maxLength:kPipeBufferSize]; + DDLogDebug(@"Pipe drained %ld bytes", (long)len); + if(len > 0) + { + drainedBytes += len; + _staticOutputBuffer[len] = '\0'; //null termination for log output of raw string + DDLogDebug(@"Pipe got raw drained string '%s'", _staticOutputBuffer); + } + } while(len > 0 && [_input hasBytesAvailable]); + DDLogDebug(@"Pipe done draining %ld bytes", (long)drainedBytes); + return @(drainedBytes); + } +} + +-(void) cleanupOutputBuffer +{ + @synchronized(self) { + if(_outputBufferByteCount > 0) + DDLogDebug(@"Pipe throwing away data in output buffer: %ld bytes", (long)_outputBufferByteCount); + _outputBuffer = _staticOutputBuffer; + _outputBufferByteCount = 0; + } +} + +-(void) process +{ + @synchronized(self) { + //only start processing if piping is possible + if(!_output) + { + DDLogDebug(@"not starting pipe processing: no output stream available"); + return; + } + if(![_output hasSpaceAvailable]) + { + DDLogWarn(@"not starting pipe processing: no space to write"); + return; + } + + //DDLogVerbose(@"starting pipe processing"); + + //try to send remaining buffered data first + if(_outputBufferByteCount > 0) + { + _outputBuffer[_outputBufferByteCount] = '\0'; //null termination for log output of raw string + DDLogDebug(@"trying to send buffered data(%lu): %s", (unsigned long)_outputBufferByteCount, _outputBuffer); + NSInteger writtenLen = [_output write:_outputBuffer maxLength:_outputBufferByteCount]; + if(writtenLen > 0) + { + if((NSUInteger) writtenLen != _outputBufferByteCount) //some bytes remaining to send + { + _outputBuffer += writtenLen; + _outputBufferByteCount -= writtenLen; + DDLogWarn(@"pipe processing sent part of buffered data: %ld", (long)writtenLen); + return; + } + else + { + //reset empty buffer + _outputBuffer = _staticOutputBuffer; + _outputBufferByteCount = 0; //everything sent + DDLogDebug(@"pipe processing sent all remaining buffered data"); + } + } + else + { + NSError* error = [_output streamError]; + DDLogError(@"pipe sending failed with error %ld domain %@ message %@", (long)error.code, error.domain, error.userInfo); + return; + } + } + + //return here if we have nothing to read + if(![_input hasBytesAvailable]) + { + DDLogVerbose(@"stopped pipe processing: nothing to read"); + return; + } + + NSInteger readLen = 0; + NSInteger writtenLen = 0; + readLen = [_input read:_outputBuffer maxLength:kPipeBufferSize]; + if(readLen > 0) + { + _outputBuffer[readLen] = '\0'; //null termination for log output of raw string + DDLogVerbose(@"RECV(%ld): %s", (long)readLen, _outputBuffer); + writtenLen = [_output write:_outputBuffer maxLength:readLen]; + if(writtenLen == -1) + { + NSError* error = [_output streamError]; + DDLogError(@"pipe sending failed with error %ld domain %@ message %@", (long)error.code, error.domain, error.userInfo); + return; + } + else if(writtenLen < readLen) + { + DDLogDebug(@"pipe could only write %ld of %ld bytes, buffering", (long)writtenLen, (long)readLen); + //set the buffer pointer to the remaining data and leave our copy loop + _outputBuffer += (size_t)writtenLen; + _outputBufferByteCount = (size_t)(readLen-writtenLen); + return; + } + } + else + DDLogDebug(@"pipe read %ld <= 0 bytes", (long)readLen); + } +} + +-(void) stream:(NSStream*) stream handleEvent:(NSStreamEvent) eventCode +{ + //DDLogVerbose(@"Pipe stream %@ has event", stream); + + //ignore events from stale streams + if(stream != _input && stream != _output) + return; + + switch(eventCode) + { + //only log open and none events + case NSStreamEventOpenCompleted: + { + DDLogDebug(@"Pipe stream %@ completed open", stream); + break; + } + + case NSStreamEventNone: + { + DDLogVerbose(@"Pipe stream %@ event none", stream); + break; + } + + //handle read and write events + case NSStreamEventHasSpaceAvailable: + { + DDLogVerbose(@"Pipe stream %@ has space available to write", stream); + [self process]; + break; + } + case NSStreamEventHasBytesAvailable: + { + DDLogVerbose(@"Pipe stream %@ has bytes available to read", stream); + [self process]; + break; + } + + //handle all other events in outer stream delegate + default: + { + DDLogVerbose(@"Pipe stream %@ delegates event to outer delegate", stream); + [_delegate stream:stream handleEvent:eventCode]; + break; + } + } +} + +@end diff --git a/Monal/Classes/MLPlaceholderViewController.m b/Monal/Classes/MLPlaceholderViewController.m new file mode 100644 index 0000000..450d5f1 --- /dev/null +++ b/Monal/Classes/MLPlaceholderViewController.m @@ -0,0 +1,22 @@ +// +// MLPlaceholderViewController.m +// Monal +// +// Created by Anurodh Pokharel on 1/5/20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import + +@interface MLPlaceholderViewController : UIViewController +@end + +@implementation MLPlaceholderViewController + +-(void) viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; +} + +@end diff --git a/Monal/Classes/MLPresenceProcessor.h b/Monal/Classes/MLPresenceProcessor.h new file mode 100644 index 0000000..4554b76 --- /dev/null +++ b/Monal/Classes/MLPresenceProcessor.h @@ -0,0 +1,17 @@ +// +// MLPresenceProcessor.h +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MLPresenceProcessor : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLPresenceProcessor.m b/Monal/Classes/MLPresenceProcessor.m new file mode 100644 index 0000000..b80dcd8 --- /dev/null +++ b/Monal/Classes/MLPresenceProcessor.m @@ -0,0 +1,13 @@ +// +// MLPresenceProcessor.m +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLPresenceProcessor.h" + +@implementation MLPresenceProcessor + +@end diff --git a/Monal/Classes/MLProcessLock.h b/Monal/Classes/MLProcessLock.h new file mode 100755 index 0000000..f8bd715 --- /dev/null +++ b/Monal/Classes/MLProcessLock.h @@ -0,0 +1,26 @@ +// +// MLProcessLock.h +// monalxmpp +// +// Created by Thilo Molitor on 26.07.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLProcessLock : NSObject + ++(void) initializeForProcess:(NSString*) processName; ++(BOOL) checkRemoteRunning:(NSString*) processName; ++(void) waitForRemoteStartup:(NSString*) processName; ++(void) waitForRemoteStartup:(NSString*) processName withLoopHandler:(monal_void_block_t _Nullable) handler; ++(void) waitForRemoteTermination:(NSString*) processName; ++(void) waitForRemoteTermination:(NSString*) processName withLoopHandler:(monal_void_block_t _Nullable) handler; ++(void) lock; ++(void) unlock; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLProcessLock.m b/Monal/Classes/MLProcessLock.m new file mode 100755 index 0000000..1f6ac4c --- /dev/null +++ b/Monal/Classes/MLProcessLock.m @@ -0,0 +1,153 @@ +// +// MLProcessLock.m +// monalxmpp +// +// Created by Thilo Molitor on 26.07.20. +// Loosely based on https://ddeville.me/2015/02/interprocess-communication-on-ios-with-berkeley-sockets/ +// and https://ddeville.me/2015/02/interprocess-communication-on-ios-with-mach-messages/ +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#include +#include + +#import "MLProcessLock.h" +#import "MLConstants.h" +#import "HelperTools.h" + + +@interface MLProcessLock() + +@end + +static NSString* _locksDir; +static char* _ownLockPath; +static volatile int _ownLockFD; + +@implementation MLProcessLock + ++(void) initializeForProcess:(NSString*) processName +{ + NSError* error; + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSString* documentsDirectory = [[HelperTools getContainerURLForPathComponents:@[]] path]; + _locksDir = [documentsDirectory stringByAppendingPathComponent:@"locks"]; + [fileManager createDirectoryAtPath:_locksDir withIntermediateDirectories:YES attributes:nil error:&error]; + if(error) + @throw [NSException exceptionWithName:@"NSError" reason:[NSString stringWithFormat:@"%@", error] userInfo:@{@"error": error}]; + [HelperTools configureFileProtectionFor:_locksDir]; + + const char* path = [[NSFileManager defaultManager] fileSystemRepresentationWithPath:[_locksDir stringByAppendingPathComponent:processName]]; + _ownLockPath = calloc(strlen(path)+1, sizeof(*_ownLockPath)); + strncpy(_ownLockPath, path, strlen(path)); + DDLogInfo(@"Set _ownLockPath to '%s'...", _ownLockPath); +} + ++(void) lock +{ + int lock; + DDLogVerbose(@"Locking process (_ownLockPath=%s)...", _ownLockPath); + @synchronized(self) { + if(_ownLockFD != 0) + { + lock = flock(_ownLockFD, LOCK_EX | LOCK_NB); + if(lock == 0) + { + DDLogVerbose(@"Process still locked, doing nothing..."); + return; + } + @throw [NSException exceptionWithName:@"LockingError" reason:[NSString stringWithFormat:@"flock returned: %d (%d) on file: %s", lock, errno, _ownLockPath] userInfo:nil]; + } + _ownLockFD = open(_ownLockPath, O_CREAT, S_IRWXU | S_IRWXG); + if(_ownLockFD == 0) + @throw [NSException exceptionWithName:@"LockingError" reason:[NSString stringWithFormat:@"failed to fopen file (%d): %s", errno, _ownLockPath] userInfo:nil]; + lock = flock(_ownLockFD, LOCK_EX | LOCK_NB); + if(lock != 0) + @throw [NSException exceptionWithName:@"LockingError" reason:[NSString stringWithFormat:@"flock returned: %d (%d) on file: %s", lock, errno, _ownLockPath] userInfo:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unlock) name:kMonalIsFreezed object:nil]; + } +} + ++(void) unlock +{ + DDLogVerbose(@"Unlocking process (_ownLockPath=%s)...", _ownLockPath); + @synchronized(self) { + if(_ownLockFD != 0) + { + close(_ownLockFD); + _ownLockFD = 0; + } + [[NSNotificationCenter defaultCenter] removeObserver:self]; + } +} + ++(BOOL) checkRemoteRunning:(NSString*) processName +{ + char const* path = [[NSFileManager defaultManager] fileSystemRepresentationWithPath:[_locksDir stringByAppendingPathComponent:processName]]; + DDLogVerbose(@"Checking if remote %@ is running (path=%s)...", processName, path); + int fd = open(path, O_CREAT, S_IRWXU | S_IRWXG); + if(fd == 0) + @throw [NSException exceptionWithName:@"LockingError" reason:[NSString stringWithFormat:@"failed to fopen file (%d): %s", errno, path] userInfo:@{@"processName": processName}]; + int lock = flock(fd, LOCK_EX | LOCK_NB); + //try again if the file was not locked + //this makes sure we don't run into race conditions after app freezes/unfreezes + if(lock == 0) + { + flock(fd, LOCK_UN); + [self sleep:0.050]; + lock = flock(fd, LOCK_EX | LOCK_NB); + } + close(fd); + DDLogVerbose(@"Remote %@ running: %@", processName, bool2str(lock != 0)); + return lock != 0; +} + ++(void) waitForRemoteStartup:(NSString*) processName +{ + [self waitForRemoteStartup:processName withLoopHandler:nil]; +} + ++(void) waitForRemoteStartup:(NSString*) processName withLoopHandler:(monal_void_block_t _Nullable) handler +{ + while(![[NSThread currentThread] isCancelled] && ![self checkRemoteRunning:processName]) + { + if(handler) + handler(); + [self sleep:1.0]; + } +} + ++(void) waitForRemoteTermination:(NSString*) processName +{ + [self waitForRemoteTermination:processName withLoopHandler:nil]; +} + ++(void) waitForRemoteTermination:(NSString*) processName withLoopHandler:(monal_void_block_t _Nullable) handler +{ + //wait 250ms (in case this method will be used by the appex to wait for the mainapp in the future) + //--> we want to make sure the mainapp *really* isn't running anymore while still not waiting too long + //(this 250ms is a tradeoff, a longer timeout would be safer but could result in long mainapp startup delays or startup kills by iOS) + //see the explanation of checkRemoteRunning: for further details + while(![[NSThread currentThread] isCancelled] && [self checkRemoteRunning:processName]) + { + if(handler) + handler(); + [self sleep:0.250]; + } +} + ++(void) sleep:(NSTimeInterval) time +{ + BOOL was_called_in_mainthread = [NSThread isMainThread]; + NSRunLoop* main_runloop = [NSRunLoop mainRunLoop]; + NSDate* timeout = [NSDate dateWithTimeIntervalSinceNow:time]; + //we have to spin the runloop instead of simply sleeping to not get killed for unresponsiveness + if(was_called_in_mainthread) + while([timeout timeIntervalSinceNow] > 0) + [main_runloop runMode:[main_runloop currentMode] beforeDate:timeout]; + else + [NSThread sleepForTimeInterval:time]; +} + +@end diff --git a/Monal/Classes/MLPubSub.h b/Monal/Classes/MLPubSub.h new file mode 100644 index 0000000..e4e61ea --- /dev/null +++ b/Monal/Classes/MLPubSub.h @@ -0,0 +1,76 @@ +// +// MLPubSub.h +// monalxmpp +// +// Created by Thilo Molitor on 20.09.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@class xmpp; +@class XMPPMessage; +@class MLXMLNode; +@class MLHandler; + +@interface MLPubSub : NSObject +{ +} + +//activate/deactivate automatic data updates +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$ID(NSString*, type), $$ID(NSDictionary*, data)) +-(void) registerForNode:(NSString*) node withHandler:(MLHandler*) handler; +//handler --> $$instance_handler given to registerForNode:withHandler: +-(void) unregisterHandler:(MLHandler*) handler forNode:(NSString*) node; + +//fetch data +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $_ID(NSDictionary*, data)) +//invalidation --> $$class_handler(xxx, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$BOOL(success)) +-(void) fetchNode:(NSString*) node from:(NSString*) jid withItemsList:(NSArray* _Nullable) itemsList andHandler:(MLHandler*) handler; + +//subscribe to node +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) +//invalidation --> $$class_handler(xxx, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$BOOL(success)) +-(void) subscribeToNode:(NSString*) node onJid:(NSString*) jid withHandler:(MLHandler*) handler; +//unsubscribe from node +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$BOOL(success), , $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) +//invalidation --> $$class_handler(xxx, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$BOOL(success), , $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) +-(void) unsubscribeFromNode:(NSString*) node forJid:(NSString*) jid withHandler:(MLHandler* _Nullable) handler; + +//configure node +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) +//invalidation --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node)) +-(void) configureNode:(NSString*) node withConfigOptions:(NSDictionary*) configOptions andHandler:(MLHandler* _Nullable) handler; + +//publish item on node +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) +//invalidation --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node)) +-(void) publishItem:(MLXMLNode*) item onNode:(NSString*) node; +-(void) publishItem:(MLXMLNode*) item onNode:(NSString*) node withConfigOptions:(NSDictionary* _Nullable) configOptions; +-(void) publishItem:(MLXMLNode*) item onNode:(NSString*) node withHandler:(MLHandler* _Nullable) handler; +-(void) publishItem:(MLXMLNode*) item onNode:(NSString*) node withConfigOptions:(NSDictionary* _Nullable) configOptions andHandler:(MLHandler* _Nullable) handler; + +//retract item from node +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node), $$ID(NSString*, itemId), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) +//invalidation --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node), $$ID(NSString*, itemId)) +-(void) retractItemWithId:(NSString*) itemId onNode:(NSString*) node; +-(void) retractItemWithId:(NSString*) itemId onNode:(NSString*) node andHandler:(MLHandler* _Nullable) handler; + +//purge whole node +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) +//invalidation --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node)) +-(void) purgeNode:(NSString*) node; +-(void) purgeNode:(NSString*) node andHandler:(MLHandler* _Nullable) handler; + +//delete whole node +//handler --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) +//invalidation --> $$class_handler(xxx, $$ID(xmpp*, account), $$BOOL(success), $$ID(NSString*, node)) +-(void) deleteNode:(NSString*) node; +-(void) deleteNode:(NSString*) node andHandler:(MLHandler* _Nullable) handler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLPubSub.m b/Monal/Classes/MLPubSub.m new file mode 100644 index 0000000..14b8b24 --- /dev/null +++ b/Monal/Classes/MLPubSub.m @@ -0,0 +1,989 @@ +// +// MLPubSub.m +// monalxmpp +// +// Created by Thilo Molitor on 20.09.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLPubSub.h" +#import "MLHandler.h" +#import "xmpp.h" +#import "MLXMLNode.h" +#import "XMPPDataForm.h" +#import "XMPPStanza.h" +#import "XMPPIQ.h" +#import "XMPPMessage.h" +#import "HelperTools.h" + +#define CURRENT_PUBSUB_DATA_VERSION @6 + +@interface MLPubSub () +{ + __weak xmpp* _account; + NSMutableDictionary* _registeredHandlers; + NSMutableArray* _queue; +} +@end + +@implementation MLPubSub + +static NSDictionary* _defaultOptions; + ++(void) initialize +{ + //TODO: wait for servers to support pubsub#publish_node_full and set it at least for bookmarks2 + _defaultOptions = @{ + @"pubsub#notify_retract": @"true", + @"pubsub#notify_delete": @"true" + }; +} + +-(id) initWithAccount:(xmpp*) account +{ + self = [super init]; + _account = account; + _registeredHandlers = [NSMutableDictionary new]; + _queue = [NSMutableArray new]; + //retry our pubsub operation as soon as possible + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccountDiscoReady:) name:kMonalAccountDiscoDone object:nil]; + return self; +} + +-(void) registerForNode:(NSString*) node withHandler:(MLHandler*) handler +{ + DDLogInfo(@"Adding PEP handler %@ for node %@", handler, node); + @synchronized(_registeredHandlers) { + if(!_registeredHandlers[node]) + _registeredHandlers[node] = [NSMutableDictionary new]; + _registeredHandlers[node][handler.id] = handler; + [_account setPubSubNotificationsForNodes:[_registeredHandlers allKeys] persistState:NO]; + } +} + +-(void) unregisterHandler:(MLHandler*) handler forNode:(NSString*) node +{ + DDLogInfo(@"Removing PEP handler %@ for node %@", handler, node); + @synchronized(_registeredHandlers) { + if(!_registeredHandlers[node]) + return; + [_registeredHandlers[node] removeObjectForKey:handler.id]; + [_account setPubSubNotificationsForNodes:[_registeredHandlers allKeys] persistState:NO]; + } +} + +-(void) handleAccountDiscoReady:(NSNotification*) notification +{ + if(_account.accountID.intValue != ((xmpp*)notification.object).accountID.intValue) + return; + //we clear the queue so that the invalidation handlers can't get called twice: + //once as invalidation of the queued operation handler and once as the invalidation of an iq handler of this operation + //note: these are two different handler object, hence the double invalidation would *not* be catched by the handler framework! + NSArray* queue; + @synchronized(_queue) { + queue = [_queue copy]; + _queue = [NSMutableArray new]; + } + for(MLHandler* handler in queue) + $call(handler, $ID(account, _account)); +} + +$$instance_handler(queuedFetchNodeHandler, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $_ID(NSArray*, itemsList), $$HANDLER(handler)) + [self fetchNode:node from:jid withItemsList:itemsList andHandler:handler]; +$$ +-(void) fetchNode:(NSString*) node from:(NSString*) jid withItemsList:(NSArray* _Nullable) itemsList andHandler:(MLHandler*) handler +{ + DDLogInfo(@"Fetching node '%@' at jid '%@' using callback %@...", node, jid, handler); + xmpp* account = _account; + + if(account.accountState < kStateBound || !account.connectionProperties.accountDiscoDone) + { + DDLogWarn(@"Queueing pubsub call until account disco is resolved..."); + [_queue addObject:$newHandlerWithInvalidation(self, queuedFetchNodeHandler, handleFetchInvalidation, $ID(node), $ID(jid), $ID(itemsList), $HANDLER(handler))]; + return; + } + + if(!account.connectionProperties.supportsPubSub) + { + DDLogWarn(@"Pubsub not supported, ignoring this call for node '%@' and jid '%@'!", node, jid); + return; + } + if(jid != nil) + { + NSDictionary* splitJid = [HelperTools splitJid:jid]; + MLAssert(splitJid[@"resource"] == nil, @"Jid MUST be a bare jid, not full jid!"); + } + + //build list of items to query (empty list means all items) + if(!itemsList) + itemsList = @[]; + NSMutableArray* queryItems = [NSMutableArray new]; + for(NSString* itemId in itemsList) + [queryItems addObject:[[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{@"id": itemId} andChildren:@[] andData:nil]]; + + //build query + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqGetType to:jid]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"items" withAttributes:@{@"node": node} andChildren:queryItems andData:nil] + ] andData:nil]]; + + [account sendIq:query withHandler:$newHandlerWithInvalidation(self, handleFetch, handleFetchInvalidation, + $ID(node), + $ID(jid), + $ID(queryItems), + $ID(data, [NSMutableDictionary new]), + $HANDLER(handler), + )]; +} + +$$instance_handler(queuedSubscribeToNodeHandler, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $$HANDLER(handler)) + [self subscribeToNode:node onJid:jid withHandler:handler]; +$$ +-(void) subscribeToNode:(NSString*) node onJid:(NSString*) jid withHandler:(MLHandler*) handler +{ + DDLogInfo(@"Subscribing to node '%@' at jid '%@' using callback %@...", node, jid, handler); + xmpp* account = _account; + + if(account.accountState < kStateBound || !account.connectionProperties.accountDiscoDone) + { + DDLogWarn(@"Queueing pubsub call until account disco is resolved..."); + [_queue addObject:$newHandlerWithInvalidation(self, queuedSubscribeToNodeHandler, handleSubscribeInvalidation, $ID(node), $ID(jid), $HANDLER(handler))]; + return; + } + + if(!account.connectionProperties.supportsPubSub) + { + DDLogWarn(@"Pubsub not supported, ignoring this call for node '%@' and jid '%@'!", node, jid); + return; + } + if(jid != nil) + { + NSDictionary* splitJid = [HelperTools splitJid:jid]; + MLAssert(splitJid[@"resource"] == nil, @"Jid MUST be a bare jid, not full jid!"); + } + + //build subscription request + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType to:jid]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"subscribe" withAttributes:@{ + @"node": node, + @"jid": account.connectionProperties.identity.jid, + } andChildren:@[] andData:nil] + ] andData:nil]]; + + [account sendIq:query withHandler:$newHandlerWithInvalidation(self, handleSubscribe, handleSubscribeInvalidation, + $ID(node), + $ID(jid), + $HANDLER(handler), + )]; +} + +$$instance_handler(queuedUnsubscribeFromNodeHandler, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSString*, jid), $_HANDLER(handler)) + [self unsubscribeFromNode:node forJid:jid withHandler:handler]; +$$ +-(void) unsubscribeFromNode:(NSString*) node forJid:(NSString*) jid withHandler:(MLHandler* _Nullable) handler +{ + DDLogInfo(@"Unsubscribing from node '%@' at jid '%@' using callback %@...", node, jid, handler); + + if(!_account.connectionProperties.accountDiscoDone) + { + DDLogWarn(@"Queueing pubsub call until account disco is resolved..."); + [_queue addObject:$newHandlerWithInvalidation(self, queuedUnsubscribeFromNodeHandler, handleUnsubscribeInvalidation, $ID(node), $ID(jid), $HANDLER(handler))]; + return; + } + + if(!_account.connectionProperties.supportsPubSub) + { + DDLogWarn(@"Pubsub not supported, ignoring this call for node '%@' and jid '%@'!", node, jid); + return; + } + + if(jid != nil) + { + NSDictionary* splitJid = [HelperTools splitJid:jid]; + MLAssert(splitJid[@"resource"] == nil, @"Jid MUST be a bare jid, not full jid!"); + } + + //build subscription request + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType to:jid]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"unsubscribe" withAttributes:@{ + @"node": node, + @"jid": _account.connectionProperties.identity.jid, + } andChildren:@[] andData:nil] + ] andData:nil]]; + + [_account sendIq:query withHandler:$newHandlerWithInvalidation(self, handleUnsubscribe, handleUnsubscribeInvalidation, + $ID(node), + $ID(jid), + $HANDLER(handler), + )]; +} + +$$instance_handler(queuedConfigureNodeHandler, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(NSDictionary*, configOptions), $_HANDLER(handler)) + [self configureNode:node withConfigOptions:configOptions andHandler:handler]; +$$ +-(void) configureNode:(NSString*) node withConfigOptions:(NSDictionary*) configOptions andHandler:(MLHandler* _Nullable) handler +{ + xmpp* account = _account; + + if(account.accountState < kStateBound || !account.connectionProperties.accountDiscoDone) + { + DDLogWarn(@"Queueing pubsub call until account disco is resolved..."); + [_queue addObject:$newHandlerWithInvalidation(self, queuedConfigureNodeHandler, handleConfigFormResultInvalidation, $ID(node), $ID(configOptions), $HANDLER(handler))]; + return; + } + + if(!account.connectionProperties.supportsPubSub) + { + DDLogWarn(@"Pubsub not supported, ignoring this call for node '%@'!", node); + return; + } + + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqGetType]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub#owner" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"configure" withAttributes:@{@"node": node} andChildren:@[] andData:nil] + ] andData:nil]]; + [account sendIq:query withHandler:$newHandlerWithInvalidation(self, handleConfigFormResult, handleConfigFormResultInvalidation, + $ID(node), + $ID(configOptions), + $HANDLER(handler) + )]; +} + +-(void) publishItem:(MLXMLNode*) item onNode:(NSString*) node +{ + [self publishItem:item onNode:node withConfigOptions:nil andHandler:nil]; +} + +-(void) publishItem:(MLXMLNode*) item onNode:(NSString*) node withHandler:(MLHandler* _Nullable) handler +{ + [self publishItem:item onNode:node withConfigOptions:nil andHandler:handler]; +} + +-(void) publishItem:(MLXMLNode*) item onNode:(NSString*) node withConfigOptions:(NSDictionary* _Nullable) configOptions +{ + [self publishItem:item onNode:node withConfigOptions:configOptions andHandler:nil]; +} + +$$instance_handler(queuedPublishItemHandler, account.pubsub, $$ID(xmpp*, account), $$ID(MLXMLNode*, item), $$ID(NSString*, node), $_ID(NSDictionary*, configOptions), $_HANDLER(handler)) + [self publishItem:item onNode:node withConfigOptions:configOptions andHandler:handler]; +$$ +-(void) publishItem:(MLXMLNode*) item onNode:(NSString*) node withConfigOptions:(NSDictionary* _Nullable) configOptions andHandler:(MLHandler* _Nullable) handler +{ + if(!_account.connectionProperties.accountDiscoDone) + { + DDLogWarn(@"Queueing pubsub call until account disco is resolved..."); + [_queue addObject:$newHandlerWithInvalidation(self, queuedPublishItemHandler, handlePublishResultInvalidation, $ID(item), $ID(node), $ID(configOptions), $HANDLER(handler))]; + return; + } + + if(!_account.connectionProperties.supportsPubSub) + { + DDLogWarn(@"Pubsub not supported, ignoring this call for node '%@'!", node); + return; + } + if(!configOptions) + configOptions = @{}; + + //update config options with our own defaults if not already present + configOptions = [self copyDefaultNodeOptions:_defaultOptions forConfigForm:nil into:configOptions]; + + [self internalPublishItem:item onNode:node withConfigOptions:configOptions andHandler:handler andIsRetry:NO]; +} + +-(void) retractItemWithId:(NSString*) itemId onNode:(NSString*) node +{ + [self retractItemWithId:itemId onNode:node andHandler:nil]; +} + +$$instance_handler(queuedRetractItemWithIdHandler, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, itemId), $$ID(NSString*, node), $_HANDLER(handler)) + [self retractItemWithId:itemId onNode:node andHandler:handler]; +$$ +-(void) retractItemWithId:(NSString*) itemId onNode:(NSString*) node andHandler:(MLHandler* _Nullable) handler +{ + xmpp* account = _account; + + if(account.accountState < kStateBound || !account.connectionProperties.accountDiscoDone) + { + DDLogWarn(@"Queueing pubsub call until account disco is resolved..."); + [_queue addObject:$newHandlerWithInvalidation(self, queuedRetractItemWithIdHandler, handleRetractResultInvalidation, $ID(itemId), $ID(node), $HANDLER(handler))]; + return; + } + + if(!account.connectionProperties.supportsPubSub) + { + DDLogWarn(@"Pubsub not supported, ignoring this call for node '%@'!", node); + return; + } + DDLogDebug(@"Retracting item '%@' on node '%@'", itemId, node); + MLXMLNode* item = [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{@"id": itemId} andChildren:@[] andData:nil]; + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"retract" withAttributes:@{@"node": node, @"notify": @"true"} andChildren:@[item] andData:nil] + ] andData:nil]]; + [account sendIq:query withHandler:$newHandlerWithInvalidation(self, handleRetractResult, handleRetractResultInvalidation, + $ID(node), + $ID(itemId), + $HANDLER(handler) + )]; +} + +-(void) purgeNode:(NSString*) node +{ + [self purgeNode:node andHandler:nil]; +} + +$$instance_handler(queuedPurgeNodeNodeHandler, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, node), $_HANDLER(handler)) + [self purgeNode:node andHandler:handler]; +$$ +-(void) purgeNode:(NSString*) node andHandler:(MLHandler* _Nullable) handler +{ + xmpp* account = _account; + + if(account.accountState < kStateBound || !account.connectionProperties.accountDiscoDone) + { + DDLogWarn(@"Queueing pubsub call until account disco is resolved..."); + [_queue addObject:$newHandlerWithInvalidation(self, queuedPurgeNodeNodeHandler, handlePurgeOrDeleteResultInvalidation, $ID(node), $HANDLER(handler))]; + return; + } + + if(!account.connectionProperties.supportsPubSub) + { + DDLogWarn(@"Pubsub not supported, ignoring this call for node '%@'!", node); + return; + } + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub#owner" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"purge" withAttributes:@{@"node": node} andChildren:@[] andData:nil] + ] andData:nil]]; + [account sendIq:query withHandler:$newHandlerWithInvalidation(self, handlePurgeOrDeleteResult, handlePurgeOrDeleteResultInvalidation, + $ID(node), + $HANDLER(handler) + )]; +} + +-(void) deleteNode:(NSString*) node +{ + [self deleteNode:node andHandler:nil]; +} + +$$instance_handler(queuedDeleteNodeHandler, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, node), $_HANDLER(handler)) + [self deleteNode:node andHandler:handler]; +$$ +-(void) deleteNode:(NSString*) node andHandler:(MLHandler* _Nullable) handler +{ + xmpp* account = _account; + + if(account.accountState < kStateBound || !account.connectionProperties.accountDiscoDone) + { + DDLogWarn(@"Queueing pubsub call until account disco is resolved..."); + [_queue addObject:$newHandlerWithInvalidation(self, queuedDeleteNodeHandler, handlePurgeOrDeleteResultInvalidation, $ID(node), $HANDLER(handler))]; + return; + } + + if(!account.connectionProperties.supportsPubSub) + { + DDLogWarn(@"Pubsub not supported, ignoring this call for node '%@'!", node); + return; + } + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub#owner" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"delete" withAttributes:@{@"node": node} andChildren:@[] andData:nil] + ] andData:nil]]; + [account sendIq:query withHandler:$newHandlerWithInvalidation(self, handlePurgeOrDeleteResult, handlePurgeOrDeleteResultInvalidation, + $ID(node), + $HANDLER(handler) + )]; +} + +//*** framework methods below + +-(NSDictionary*) getInternalData +{ + @synchronized(_queue) { + return @{ + @"version": CURRENT_PUBSUB_DATA_VERSION, + @"queue": [_queue copy], + }; + } +} + +-(void) setInternalData:(NSDictionary*) data +{ + DDLogDebug(@"Loading internal pubsub data"); + @synchronized(_queue) { + if(!data[@"version"] || ![data[@"version"] isEqualToNumber:CURRENT_PUBSUB_DATA_VERSION]) + return; //ignore old data + _queue = [data[@"queue"] mutableCopy]; + } +} + +-(void) invalidateQueue +{ + //we clear the queue so that the invalidation handlers can't get called twice: + //once as invalidation of the queued operation handler and once as the invalidation of an iq handler of this operation + //note: these are two different handler object, hence the double invalidation would *not* be catched by the handler framework! + NSArray* queue; + @synchronized(_queue) { + queue = [_queue copy]; + _queue = [NSMutableArray new]; + } + for(MLHandler* handler in queue) + $invalidate(handler, $ID(account, _account)); +} + +-(void) handleHeadlineMessage:(XMPPMessage*) messageNode +{ + NSString* node = [messageNode findFirst:@"//{http://jabber.org/protocol/pubsub#event}event/{*}*@node"]; + if(!node) + { + DDLogWarn(@"Got pubsub data without node attribute!"); + return; + } + + if(!_account.connectionProperties.supportsPubSub) + { + DDLogError(@"Pubsub not supported, ignoring this call for headline message (THIS SHOULD NEVER HAPPEN): %@", messageNode); + return; + } + + DDLogDebug(@"Handling pubsub data for node '%@'", node); + + //handle node purge + if([messageNode check:@"//{http://jabber.org/protocol/pubsub#event}event/purge"]) + { + DDLogDebug(@"Handling purge"); + [self callHandlersForNode:node andJid:messageNode.fromUser withType:@"purge" andData:nil]; + return; //we are done here (no items element for purge events) + } + + //handle node delete + if([messageNode check:@"//{http://jabber.org/protocol/pubsub#event}event/delete"]) + { + DDLogDebug(@"Handling delete"); + [self callHandlersForNode:node andJid:messageNode.fromUser withType:@"delete" andData:nil]; + return; //we are done here (no items element for delete events) + } + + //handle published items + MLXMLNode* items = [messageNode findFirst:@"//{http://jabber.org/protocol/pubsub#event}event/items"]; + if(!items) + { + DDLogWarn(@"Got pubsub event data without items node, ignoring!"); + return; + } + + //handle item delete + if([items check:@"retract"]) + { + DDLogDebug(@"Handling retract"); + NSMutableDictionary* data = [self handleRetraction:items fromJid:messageNode.fromUser withData:[NSMutableDictionary new]]; + if(data) //ignore unexpected/wrong data + [self callHandlersForNode:node andJid:messageNode.fromUser withType:@"retract" andData:data]; + } + + //handle xep-0060 6.5.6 (check if payload is included or if it has to be fetched separately) + if([items check:@"item/{*}*"]) + { + DDLogDebug(@"Handling publish"); + NSMutableDictionary* data = [self handleItems:items fromJid:messageNode.fromUser withData:[NSMutableDictionary new]]; + if(data) //ignore unexpected/wrong data + [self callHandlersForNode:node andJid:messageNode.fromUser withType:@"publish" andData:data]; + } + else + { + DDLogDebug(@"Handling truncated publish"); + [self fetchNode:node from:messageNode.fromUser withItemsList:[items find:@"item@id"] andHandler:$newHandler(self, handleInternalFetch, $ID(node))]; + } +} + +//*** internal methods below + +-(void) internalPublishItem:(MLXMLNode*) item onNode:(NSString*) node withConfigOptions:(NSDictionary*) configOptions andHandler:(MLHandler* _Nullable) handler andIsRetry:(BOOL) is_retry +{ + DDLogDebug(@"Publishing item on node '%@': %@", node, item); + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"publish" withAttributes:@{@"node": node} andChildren:@[item] andData:nil] + ] andData:nil]]; + //only add publish-options if present + if([configOptions count] > 0) + [(MLXMLNode*)[query findFirst:@"{http://jabber.org/protocol/pubsub}pubsub"] addChildNode:[[MLXMLNode alloc] initWithElement:@"publish-options" withAttributes:@{} andChildren:@[ + [[XMPPDataForm alloc] initWithType:@"submit" formType:@"http://jabber.org/protocol/pubsub#publish-options" andDictionary:configOptions] + ] andData:nil]]; + [_account sendIq:query withHandler:$newHandlerWithInvalidation(self, handlePublishResult, handlePublishResultInvalidation, + $ID(item), + $ID(node), + $ID(configOptions), + $HANDLER(handler), + $BOOL(is_retry) + )]; +} + +//NOTE: this will be called for iq *or* message stanzas carrying pubsub data. +-(NSMutableDictionary*) handleItems:(MLXMLNode* _Nullable) items fromJid:(NSString* _Nullable) jid withData:(NSMutableDictionary*) data +{ + if(!items) + { + DDLogWarn(@"Got pubsub data without items node!"); + return nil; + } + + NSString* node = [items findFirst:@"/@node"]; + if(!node) + { + DDLogWarn(@"Got pubsub data without node attribute!"); + return nil; + } + DDLogDebug(@"Processing pubsub data from jid '%@' for node '%@'", jid, node); + for(MLXMLNode* item in [items find:@"item"]) + { + NSString* itemId = [item findFirst:@"/@id"]; + if(!itemId) + itemId = @""; + data[itemId] = [item copy]; //make a copy to make sure the original iq stanza won't be changed by a handler modifying the items + } + return data; +} + +//NOTE: this will be called for message stanzas carrying pubsub data. +-(NSMutableDictionary*) handleRetraction:(MLXMLNode* _Nullable) items fromJid:(NSString* _Nullable) jid withData:(NSMutableDictionary*) data +{ + if(!items) + { + DDLogWarn(@"Got pubsub retraction without items node!"); + return nil; + } + + NSString* node = [items findFirst:@"/@node"]; + if(!node) + { + DDLogWarn(@"Got pubsub data without node attribute!"); + return nil; + } + DDLogDebug(@"Removing some pubsub items from jid '%@' for node '%@'", jid, node); + for(MLXMLNode* item in [items find:@"retract"]) + { + NSString* itemId = [item findFirst:@"/@id"]; + if(!itemId) + itemId = @""; + DDLogDebug(@"Deleting pubsub item with id '%@' from jid '%@' for node '%@'", itemId, jid, node); + data[itemId] = @YES; + } + return data; +} + +-(void) callHandlersForNode:(NSString*) node andJid:(NSString*) jid withType:(NSString*) type andData:(NSDictionary*) data +{ + xmpp* account = _account; + DDLogInfo(@"Calling pubsub handlers for node '%@' (and jid '%@')", node, jid); + NSDictionary* handlers; + @synchronized(_registeredHandlers) { + handlers = [[NSDictionary alloc] initWithDictionary:_registeredHandlers[node] copyItems:YES]; + } + for(NSString* handlerId in handlers) + $call(handlers[handlerId], + $ID(account), + $ID(node), + $ID(jid), + $ID(type), + $ID(data) + ); + DDLogDebug(@"All pubsub handlers called"); +} + +-(NSDictionary*) copyDefaultNodeOptions:(NSDictionary*) defaultOptions forConfigForm:(XMPPDataForm* _Nullable) configForm into:(NSDictionary*) configOptions +{ + NSMutableDictionary* retval = [configOptions mutableCopy]; + for(NSString* option in defaultOptions) + if((configForm == nil || configForm[option] != nil) && retval[option] == nil) + retval[option] = defaultOptions[option]; + return retval; +} + +$$instance_handler(handleSubscribeInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$ID(NSString*, node), $$ID(NSString*, jid), $$HANDLER(handler)) + //invalidate our user handler + $invalidate(handler, + $ID(account), + $BOOL(success, NO), + $ID(node), + $ID(jid), + $ID(reason), + ); +$$ + +$$instance_handler(handleSubscribe, account.pubsub, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, node), $$ID(NSString*, jid), $$HANDLER(handler)) + if([iqNode check:@"/"]) + { + DDLogError(@"Got error iq for pubsub subscribe request: %@", iqNode); + //call subscribe callback (if given) with error iq node + $call(handler, + $ID(account), + $BOOL(success, NO), + $ID(node), + $ID(jid, iqNode.fromUser), + $ID(errorIq, iqNode) + ); + return; + } + + if([iqNode check:@"{http://jabber.org/protocol/pubsub}pubsub/subscription", node, account.connectionProperties.identity.jid]) + { + DDLogDebug(@"Successfully subscribed to node '%@' on jid '%@' for '%@'...", node, iqNode.fromUser, account.connectionProperties.identity.jid); + + //call subscribe callback (if given) + $call(handler, + $ID(account), + $BOOL(success, YES), + $ID(node), + $ID(jid, iqNode.fromUser) + ); + } + else + { + DDLogError(@"Could not subscribe to node '%@' on jid '%@' for '%@': %@", node, iqNode.fromUser, account.connectionProperties.identity.jid, iqNode); + + //call subscribe callback (if given) with error iq node + $call(handler, + $ID(account), + $BOOL(success, NO), + $ID(node), + $ID(jid, iqNode.fromUser), + $ID(errorReason, @"Unexpected iq result (wrong node or jid)!") + ); + } +$$ + +$$instance_handler(handleUnsubscribeInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$ID(NSString*, node), $$ID(NSString*, jid), $_HANDLER(handler)) + //invalidate our user handler + $invalidate(handler, + $ID(account), + $BOOL(success, NO), + $ID(node), + $ID(jid), + $ID(reason), + ); +$$ + +$$instance_handler(handleUnsubscribe, account.pubsub, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, node), $$ID(NSString*, jid), $_HANDLER(handler)) + if([iqNode check:@"/"]) + { + DDLogError(@"Got error iq from pubsub unsubscribe request: %@", iqNode); + //call unsubscribe callback (if given) with error iq node + $call(handler, + $ID(account), + $BOOL(success, NO), + $ID(node), + $ID(jid, iqNode.fromUser), + $ID(errorIq, iqNode) + ); + return; + } + + if([iqNode check:@"{http://jabber.org/protocol/pubsub}pubsub/subscription", node, jid]) + { + DDLogDebug(@"Successfully unsubscribed from node '%@' on jid '%@'...", node, iqNode.fromUser); + + //call unsubscribe callback (if given) + $call(handler, + $ID(account), + $BOOL(success, YES), + $ID(node), + $ID(jid, iqNode.fromUser) + ); + } + else + { + DDLogError(@"Could not unsubscribe from node '%@' on jid '%@': %@", node, iqNode.fromUser, iqNode); + + //call unsubscribe callback (if given) with error iq node + $call(handler, + $ID(account), + $BOOL(success, NO), + $ID(node), + $ID(jid, iqNode.fromUser), + $ID(errorReason, @"Unexpected iq result (wrong node or jid)!") + ); + } +$$ + +$$instance_handler(handleFetchInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$ID(NSString*, node), $$ID(NSString*, jid), $$HANDLER(handler)) + //invalidate user handler + $invalidate(handler, + $ID(account), + $BOOL(success, NO), + $ID(node), + $ID(jid), + $ID(reason), + ); +$$ + +$$instance_handler(handleFetch, account.pubsub, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, node), $$ID(NSString*, jid), $$ID(NSMutableArray*, queryItems), $$ID(NSMutableDictionary*, data), $$HANDLER(handler)) + if([iqNode check:@"/"]) + { + DDLogError(@"Got error iq for pubsub fetch request: %@", iqNode); + //call fetch callback (if given) with error iq node + $call(handler, + $ID(account), + $BOOL(success, NO), + $ID(node), + $ID(jid, iqNode.fromUser), + $ID(errorIq, iqNode) + ); + return; + } + + NSString* first = [iqNode findFirst:@"{http://jabber.org/protocol/pubsub}pubsub/{http://jabber.org/protocol/rsm}set/first#"]; + NSString* last = [iqNode findFirst:@"{http://jabber.org/protocol/pubsub}pubsub/{http://jabber.org/protocol/rsm}set/last#"]; + NSUInteger index = [[iqNode findFirst:@"{http://jabber.org/protocol/pubsub}pubsub/{http://jabber.org/protocol/rsm}set/first@index|int"] unsignedIntegerValue]; + NSUInteger total_count = [[iqNode findFirst:@"{http://jabber.org/protocol/pubsub}pubsub/{http://jabber.org/protocol/rsm}set/count#|int"] unsignedIntegerValue]; + NSUInteger items_count = [[iqNode find:@"{http://jabber.org/protocol/pubsub}pubsub/items/item"] count]; + //check for rsm paging + if( + !last || //no rsm at all + [last isEqualToString:first] || //reached end of rsm (only one element, e.g. last==first) + index + items_count == total_count //reached end of rsm per rsm xep (this is a SHOULD) + ) { + //--> process data *and* inform handlers of new data + [self handleItems:[iqNode findFirst:@"{http://jabber.org/protocol/pubsub}pubsub/items"] fromJid:iqNode.fromUser withData:data]; + //call fetch callback (if given) + $call(handler, + $ID(account), + $BOOL(success, YES), + $ID(node), + $ID(jid, iqNode.fromUser), + $ID(data) + ); + } + else if(first && last) + { + //only process data but *don't* call fetch callback because the data is still partial + [self handleItems:[iqNode findFirst:@"{http://jabber.org/protocol/pubsub}pubsub/items"] fromJid:iqNode.fromUser withData:data]; + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqGetType to:iqNode.fromUser]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"items" withAttributes:@{@"node": node} andChildren:queryItems andData:nil], + [[MLXMLNode alloc] initWithElement:@"set" andNamespace:@"http://jabber.org/protocol/rsm" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"after" withAttributes:@{} andChildren:@[] andData:last] + ] andData:nil] + ] andData:nil]]; + [account sendIq:query withHandler:$newHandlerWithInvalidation(self, handleFetch, handleFetchInvalidation, + $ID(node), + $ID(jid, iqNode.fromUser), + $ID(queryItems), + $ID(data), + $HANDLER(handler) + )]; + } +$$ + +$$instance_handler(handleInternalFetch, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, node), $$BOOL(success), $$ID(NSString*, jid), $_ID(NSDictionary*, data)) + if(success != NO && data != nil) //ignore errors (--> ignore invalidations, too) + [self callHandlersForNode:node andJid:jid withType:@"publish" andData:data]; +$$ + +$$instance_handler(handleConfigFormResultInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$ID(NSString*, node), $_HANDLER(handler)) + //invalidate user handler + $invalidate(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(reason)); +$$ + +$$instance_handler(handleConfigFormResult, account.pubsub, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, node), $$ID(NSDictionary*, configOptions), $_HANDLER(handler)) + if([iqNode check:@"/"]) + { + DDLogError(@"Got error iq for pubsub configure request 1: %@", iqNode); + //signal error if a handler was given + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(errorIq, iqNode)); + return; + } + + XMPPDataForm* dataForm = [[iqNode findFirst:@"{http://jabber.org/protocol/pubsub#owner}pubsub/configure/\\{http://jabber.org/protocol/pubsub#node_config}form\\"] copy]; + if(!dataForm) + { + DDLogError(@"Server returned invalid config form, aborting!"); + //abort config operation + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub#owner" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"configure" withAttributes:@{@"node": node} andChildren:@[ + [[XMPPDataForm alloc] initWithType:@"cancel" andFormType:@"http://jabber.org/protocol/pubsub#node_config"] + ] andData:nil] + ] andData:nil]]; + [account send:query]; + //signal error if a handler was given + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(errorReason, NSLocalizedString(@"Unexpected server response: invalid PEP config form", @""))); + return; + } + + //update config options with our own defaults if not already present + configOptions = [self copyDefaultNodeOptions:_defaultOptions forConfigForm:dataForm into:configOptions]; + + for(NSString* option in configOptions) + { + if(!dataForm[option]) + { + DDLogError(@"Server returned config form not containing the required fields or options, aborting! Required field: %@", option); + //abort config operation + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub#owner" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"configure" withAttributes:@{@"node": node} andChildren:@[ + [[XMPPDataForm alloc] initWithType:@"cancel" andFormType:@"http://jabber.org/protocol/pubsub#node_config"] + ] andData:nil] + ] andData:nil]]; + [account send:query]; + //signal error if a handler was given + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(errorReason, NSLocalizedString(@"Unexpected server response: missing required fields in PEP config form", @""))); + return; + } + else + dataForm[option] = configOptions[option]; //change requested value + } + + //reconfigure the node + dataForm.type = @"submit"; + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + [query addChildNode:[[MLXMLNode alloc] initWithElement:@"pubsub" andNamespace:@"http://jabber.org/protocol/pubsub#owner" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"configure" withAttributes:@{@"node": node} andChildren:@[dataForm] andData:nil] + ] andData:nil]]; + [account sendIq:query withHandler:$newHandlerWithInvalidation(self, handleConfigureResult, handleConfigureResultInvalidation, + $ID(node), + $HANDLER(handler) + )]; +$$ + +$$instance_handler(handleConfigureResultInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$ID(NSString*, node), $_HANDLER(handler)) + //invalidate user handler + $invalidate(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(reason)); +$$ + +$$instance_handler(handleConfigureResult, account.pubsub, $$ID(xmpp*, account), $$ID(NSString*, node), $$ID(XMPPIQ*, iqNode), $_HANDLER(handler)) + if([iqNode check:@"/"]) + { + DDLogError(@"Got error iq for pubsub configure request 2: %@", iqNode); + //signal error if a handler was given + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(errorIq, iqNode)); + return; + } + //inform handler of successful completion of config request + $call(handler, $ID(account), $BOOL(success, YES), $ID(node)); +$$ + +//this is a user handler for configureNode: called from handlePublishResult +$$instance_handler(handlePublishAgainInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$BOOL(success), $$ID(NSString*, node), $_HANDLER(handler)) + //invalidate user handler + $invalidate(handler, $ID(account), $BOOL(success), $ID(node), $ID(reason)); +$$ + +//this is a user handler for configureNode: called from handlePublishResult +$$instance_handler(handlePublishAgain, account.pubsub, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $$ID(MLXMLNode*, item), $$ID(NSString*, node), $$ID(NSDictionary*, configOptions), $_HANDLER(handler)) + if(!success) + { + DDLogError(@"Publish failed for node '%@' even after configuring it!", node); + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(errorIq), $ID(errorReason)); + return; + } + + //try again + [self internalPublishItem:item onNode:node withConfigOptions:configOptions andHandler:handler andIsRetry:YES]; +$$ + +//this is a user handler for internalPublishItem: called from handlePublishResult +$$instance_handler(handleConfigureAfterPublishInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$BOOL(success), $$ID(NSString*, node), $_HANDLER(handler)) + //invalidate user handler + $invalidate(handler, $ID(account), $BOOL(success), $ID(node), $ID(reason)); +$$ + +//this is a user handler for internalPublishItem: called from handlePublishResult +$$instance_handler(handleConfigureAfterPublish, account.pubsub, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $$ID(NSString*, node), $$ID(NSDictionary*, configOptions), $_HANDLER(handler)) + if(!success) + { + DDLogError(@"Second publish attempt failed again for node '%@', not configuring it!", node); + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(errorIq), $ID(errorReason)); + return; + } + + //configure node after publishing it + [self configureNode:node withConfigOptions:configOptions andHandler:handler]; +$$ + +$$instance_handler(handlePublishResultInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$ID(MLXMLNode*, item), $$ID(NSString*, node), $$ID(NSDictionary*, configOptions), $_HANDLER(handler)) + //invalidate user handler + $invalidate(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(reason)); +$$ + +$$instance_handler(handlePublishResult, account.pubsub, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(MLXMLNode*, item), $$ID(NSString*, node), $$ID(NSDictionary*, configOptions), $_HANDLER(handler), $$BOOL(is_retry)) + if([iqNode check:@"/"]) + { + //NOTE: workaround for old ejabberd versions < 23.10 only supporting two special settings as preconditions + if([@"http://www.process-one.net/en/ejabberd/" isEqualToString:account.connectionProperties.serverIdentity] && [configOptions count] > 0 && [iqNode check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}resource-constraint"]) + { + DDLogError(@"ejabberd (< 23.10) workaround for old preconditions handling active for node: %@", node); + + //make sure we don't try all preconditions from configOptions again: only these two listed preconditions are safe to use with ejabberd + NSMutableDictionary* publishPreconditions = [NSMutableDictionary new]; + if(configOptions[@"pubsub#persist_items"]) + publishPreconditions[@"pubsub#persist_items"] = configOptions[@"pubsub#persist_items"]; + if(configOptions[@"pubsub#access_model"]) + publishPreconditions[@"pubsub#access_model"] = configOptions[@"pubsub#access_model"]; + + [self internalPublishItem:item onNode:node withConfigOptions:publishPreconditions andHandler:$newHandlerWithInvalidation(self, handleConfigureAfterPublish, handleConfigureAfterPublishInvalidation, + $ID(node), + $ID(configOptions), + $HANDLER(handler) + ) andIsRetry:NO]; + return; + } + + //check if this node is already present and configured --> reconfigure it according to our access-model + if(!is_retry && [iqNode check:@"error/{http://jabber.org/protocol/pubsub#errors}precondition-not-met"]) + { + DDLogWarn(@"Node precondition not met, reconfiguring node: %@", node); + [self configureNode:node withConfigOptions:configOptions andHandler:$newHandlerWithInvalidation(self, handlePublishAgain, handlePublishAgainInvalidation, + $ID(item), + $ID(node), + $ID(configOptions), //modern servers support XEP-0060 Version 1.15.0 (2017-12-12) --> all node config options are allowed as preconditions + $HANDLER(handler) + )]; + return; + } + if(is_retry && [iqNode check:@"error/{http://jabber.org/protocol/pubsub#errors}precondition-not-met"]) + DDLogError(@"Node precondition not met even after reconfiguring node, aborting: %@", node); + + //all other errors are real errors --> inform user handler + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(errorIq, iqNode)); + return; + } + + //no errors means everything worked out as expected + $call(handler, $ID(account), $BOOL(success, YES), $ID(node)); +$$ + +$$instance_handler(handleRetractResultInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$ID(NSString*, node), $$ID(NSString*, itemId), $_HANDLER(handler)) + //invalidate user handler + $invalidate(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(itemId), $ID(reason)); +$$ + +$$instance_handler(handleRetractResult, account.pubsub, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, node), $$ID(NSString*, itemId), $_HANDLER(handler)) + if([iqNode check:@"/"]) + { + DDLogError(@"Retract for item '%@' of node '%@' failed: %@", itemId, node, iqNode); + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(itemId), $ID(errorIq, iqNode)); + return; + } + $call(handler, $ID(account), $BOOL(success, YES), $ID(node), $ID(itemId)); +$$ + +$$instance_handler(handlePurgeOrDeleteResultInvalidation, account.pubsub, $$ID(xmpp*, account), $_ID(NSString*, reason), $$ID(NSString*, node), $_HANDLER(handler)) + //invalidate user handler + $invalidate(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(reason)); +$$ + +$$instance_handler(handlePurgeOrDeleteResult, account.pubsub, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, node), $_HANDLER(handler)) + if([iqNode check:@"/"]) + { + DDLogError(@"Purge/Delete of node '%@' failed: %@", node, iqNode); + $call(handler, $ID(account), $BOOL(success, NO), $ID(node), $ID(errorIq, iqNode)); + return; + } + $call(handler, $ID(account), $BOOL(success, YES), $ID(node)); +$$ + +@end diff --git a/Monal/Classes/MLPubSubProcessor.h b/Monal/Classes/MLPubSubProcessor.h new file mode 100644 index 0000000..971139b --- /dev/null +++ b/Monal/Classes/MLPubSubProcessor.h @@ -0,0 +1,15 @@ +// +// MLPubSubProcessor.h +// monalxmpp +// +// Created by Thilo Molitor on 31.10.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +NS_ASSUME_NONNULL_BEGIN + +@interface MLPubSubProcessor : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLPubSubProcessor.m b/Monal/Classes/MLPubSubProcessor.m new file mode 100644 index 0000000..f2005e7 --- /dev/null +++ b/Monal/Classes/MLPubSubProcessor.m @@ -0,0 +1,828 @@ +// +// MLPubSubProcessor.m +// monalxmpp +// +// Created by Thilo Molitor on 31.10.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import + +#import "MLConstants.h" +#import "MLPubSubProcessor.h" +#import "MLPubSub.h" +#import "MLHandler.h" +#import "xmpp.h" +#import "DataLayer.h" +#import "MLImageManager.h" +#import "MLNotificationQueue.h" +#import "MLMucProcessor.h" +#import "XMPPIQ.h" +#import "HelperTools.h" + +@interface MLPubSubProcessor() + +@end + +@interface MLMucProcessor () +-(void) sendDiscoQueryFor:(NSString*) roomJid withJoin:(BOOL) join andBookmarksUpdate:(BOOL) updateBookmarks; +-(void) sendJoinPresenceFor:(NSString*) room; +-(NSString*) calculateNickForMuc:(NSString*) room; +@end + +@implementation MLPubSubProcessor + +$$class_handler(mdsHandler, $$ID(xmpp*, account), $$ID(NSString*, jid), $$ID(NSString*, type), $_ID((NSDictionary*), data)) + DDLogDebug(@"Got new mds displayed status from '%@' (should be own jid)...", jid); + if(![jid isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring mds update not coming from our own jid"); + return; + } + + if([type isEqualToString:@"publish"]) + [account updateMdsData:data]; +$$ + +$$class_handler(handleMdsFetchResult, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $_ID((NSDictionary*), data)) + if(!success) + { + //item-not-found means: no mds items in storage --> use an empty data dict + if([errorIq check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}item-not-found"]) + data = @{}; + else + { + DDLogWarn(@"Could not fetch mds from pep, doing nothing!"); + return; + } + } + + //call +notify handler to process our data dictionary containing all mds items + [account updateMdsData:data]; +$$ + +$$class_handler(avatarHandler, $$ID(xmpp*, account), $$ID(NSString*, jid), $$ID(NSString*, type), $_ID((NSDictionary*), data)) + DDLogDebug(@"Got new avatar metadata from '%@'", jid); + if([type isEqualToString:@"publish"]) + { + for(NSString* entry in data) + { + MLXMLNode* metadata = [data[entry] findFirst:@"{urn:xmpp:avatar:metadata}metadata/info"]; + NSString* avatarHash = [metadata findFirst:@"/@id"]; + if(!avatarHash) //the user disabled his avatar + { + DDLogInfo(@"User '%@' disabled his avatar", jid); + [[MLImageManager sharedInstance] setIconForContact:[MLContact createContactFromJid:jid andAccountID:account.accountID] WithData:nil]; + [[DataLayer sharedInstance] setAvatarHash:@"" forContact:jid andAccount:account.accountID]; + //delete cache to make sure the image will be regenerated + [[MLImageManager sharedInstance] purgeCacheForContact:jid andAccount:account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": [MLContact createContactFromJid:jid andAccountID:account.accountID] + }]; + } + else + { + NSString* currentHash = [[DataLayer sharedInstance] getAvatarHashForContact:jid andAccount:account.accountID]; + if(currentHash && [avatarHash isEqualToString:currentHash]) + { + DDLogInfo(@"Avatar hash of '%@' is the same, we don't need to update our avatar image data", jid); + break; + } + //only allow a maximum of 72KiB of image data when in appex due to appex memory limits + //--> ignore metadata elements bigger than this size and only hande them once not in appex anymore + NSUInteger avatarByteSize = [[metadata findFirst:@"/@bytes|int"] unsignedIntegerValue]; + if(![HelperTools isAppExtension] || avatarByteSize < 128 * 1024) + [account.pubsub fetchNode:@"urn:xmpp:avatar:data" from:jid withItemsList:@[avatarHash] andHandler:$newHandler(self, handleAvatarFetchResult, $ID(metadata))]; + else + { + DDLogWarn(@"Not loading avatar image of '%@' because it is too big to be handled in appex (%lu bytes), rescheduling it to be fetched in mainapp", jid, (unsigned long)avatarByteSize); + [account addReconnectionHandler:$newHandler(self, fetchAvatarAgain, $ID(jid), $ID(avatarHash), $ID(metadata))]; + } + } + break; //we only want to process the first item (this should also be the only item) + } + if([data count] > 1) + DDLogWarn(@"Got more than one avatar metadata item!"); + } + else + { + DDLogInfo(@"User %@ disabled his avatar", jid); + [[MLImageManager sharedInstance] setIconForContact:[MLContact createContactFromJid:jid andAccountID:account.accountID] WithData:nil]; + [[DataLayer sharedInstance] setAvatarHash:@"" forContact:jid andAccount:account.accountID]; + //delete cache to make sure the image will be regenerated + [[MLImageManager sharedInstance] purgeCacheForContact:jid andAccount:account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": [MLContact createContactFromJid:jid andAccountID:account.accountID] + }]; + } +$$ + +//this handler will simply retry the fetchNode for urn:xmpp:avatar:data if in mainapp +$$class_handler(fetchAvatarAgain, $$ID(xmpp*, account), $$ID(NSString*, jid), $$ID(NSString*, avatarHash), $$ID(MLXMLNode*, metadata)) + if([HelperTools isAppExtension]) + { + DDLogWarn(@"Not loading avatar image of '%@' because we are still in appex, rescheduling it again!", jid); + [account addReconnectionHandler:$newHandler(self, fetchAvatarAgain, $ID(jid), $ID(avatarHash), $ID(metadata))]; + } + else + [account.pubsub fetchNode:@"urn:xmpp:avatar:data" from:jid withItemsList:@[avatarHash] andHandler:$newHandler(self, handleAvatarFetchResult, $ID(metadata))]; +$$ + +$$class_handler(handleAvatarFetchResult, $$ID(xmpp*, account), $$ID(NSString*, jid), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(XMPPIQ*, errorReason), $_ID((NSDictionary*), data), $$ID(MLXMLNode*, metadata)) + //ignore errors here (e.g. simply don't update the avatar image) + //(this should never happen if other clients and servers behave properly) + if(!success) + { + DDLogWarn(@"Got avatar image fetch error from jid %@: errorIq=%@, errorReason=%@", jid, errorIq, errorReason); + return; + } + + for(NSString* avatarHash in data) + { + //this should be small enough to not crash the appex when loading the image from file later on but large enough to have excellent quality + NSData* avatarData = [data[avatarHash] findFirst:@"{urn:xmpp:avatar:data}data#|base64"]; + UIImage* image = nil; + if([[metadata findFirst:@"/@type"] hasPrefix:@"image/svg"]) + image = (UIImage*)nilExtractor(PMKHang([HelperTools renderUIImageFromSVGData:avatarData])); + else + image = [UIImage imageWithData:avatarData]; + if(image == nil) + { + DDLogWarn(@"Failed to load avatar of %@", jid); + return; + } + //this upper limit is roughly 1.4MiB memory (600x600 with 4 byte per pixel) + if(![HelperTools isAppExtension] || image.size.width * image.size.height < 600 * 600) + { + NSData* imageData = [HelperTools resizeAvatarImage:image withCircularMask:YES toMaxBase64Size:256000]; + [[MLImageManager sharedInstance] setIconForContact:[MLContact createContactFromJid:jid andAccountID:account.accountID] WithData:imageData]; + [[DataLayer sharedInstance] setAvatarHash:avatarHash forContact:jid andAccount:account.accountID]; + //delete cache to make sure the image will be regenerated + [[MLImageManager sharedInstance] purgeCacheForContact:jid andAccount:account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": [MLContact createContactFromJid:jid andAccountID:account.accountID] + }]; + DDLogInfo(@"Avatar of '%@' fetched and updated successfully", jid); + } + else + { + DDLogWarn(@"Not loading avatar image of '%@' because it is too big to be processed in appex (%lux%lu pixels), rescheduling it to be fetched in mainapp", jid, (unsigned long)image.size.width, (unsigned long)image.size.height); + [account addReconnectionHandler:$newHandler(self, fetchAvatarAgain, $ID(jid), $ID(avatarHash), $ID(metadata))]; + } + } +$$ + +$$class_handler(rosterNameHandler, $$ID(xmpp*, account), $$ID(NSString*, jid), $$ID(NSString*, type), $_ID((NSDictionary*), data)) + //new/updated nickname + if([type isEqualToString:@"publish"]) + { + for(NSString* itemId in data) + { + if([jid isEqualToString:account.connectionProperties.identity.jid]) //own roster name + { + DDLogInfo(@"Got own nickname: %@", [data[itemId] findFirst:@"{http://jabber.org/protocol/nick}nick#"]); + NSMutableDictionary* accountDic = [[NSMutableDictionary alloc] initWithDictionary:[[DataLayer sharedInstance] detailsForAccount:account.accountID] copyItems:YES]; + accountDic[kRosterName] = [data[itemId] findFirst:@"{http://jabber.org/protocol/nick}nick#"]; + [[DataLayer sharedInstance] updateAccounWithDictionary:accountDic]; + } + else //roster name of contact + { + DDLogInfo(@"Got nickname of %@: %@", jid, [data[itemId] findFirst:@"{http://jabber.org/protocol/nick}nick#"]); + [[DataLayer sharedInstance] setFullName:[data[itemId] findFirst:@"{http://jabber.org/protocol/nick}nick#"] forContact:jid andAccount:account.accountID]; + MLContact* contact = [MLContact createContactFromJid:jid andAccountID:account.accountID]; + if(contact) //ignore updates for jids not in our roster + { + //delete cache to make sure the image will be regenerated + [[MLImageManager sharedInstance] purgeCacheForContact:jid andAccount:account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": contact + }]; + } + } + break; //we only need the first item (there should be only one item in the first place) + } + } + //deleted/purged node or retracted item + else + { + if([jid isEqualToString:account.connectionProperties.identity.jid]) //own roster name + { + DDLogInfo(@"Own nickname got retracted"); + NSMutableDictionary* accountDic = [[NSMutableDictionary alloc] initWithDictionary:[[DataLayer sharedInstance] detailsForAccount:account.accountID] copyItems:NO]; + accountDic[kRosterName] = @""; + [[DataLayer sharedInstance] updateAccounWithDictionary:accountDic]; + } + else + { + DDLogInfo(@"Nickname of %@ got retracted", jid); + [[DataLayer sharedInstance] setFullName:@"" forContact:jid andAccount:account.accountID]; + MLContact* contact = [MLContact createContactFromJid:jid andAccountID:account.accountID]; + if(contact) //ignore updates for jids not in our roster + { + //delete cache to make sure the image will be regenerated + [[MLImageManager sharedInstance] purgeCacheForContact:jid andAccount:account.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": contact + }]; + } + } + } +$$ + +$$class_handler(bookmarks2Handler, $$ID(xmpp*, account), $$ID(NSString*, jid), $$ID(NSString*, type), $_ID((NSDictionary*), data)) + if(!account.connectionProperties.supportsBookmarksCompat) + { + DDLogWarn(@"Ignoring new XEP-0402 bookmarks, server does not support syncing between XEP-0048 and XEP-0402!"); + return; + } + + //type will be "publish", "retract", "purge" or "delete". "publish" and "retract" will have the data dictionary filled with id --> data pairs + //the data for "publish" is the item node with the given id, the data for "retract" is always @YES + if(![jid isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring bookmarks update not coming from our own jid"); + return; + } + + NSSet* ownFavorites = [[DataLayer sharedInstance] listMucsForAccount:account.accountID]; + + //new/updated bookmarks + if([type isEqualToString:@"publish"]) + { + //iterate through all conference elements provided + for(NSString* itemId in data) + { + //we ignore the conference name (the name will be taken from the muc itself) + //NSString* name = [data[itemId] findFirst:@"{urn:xmpp:bookmarks:1}conference@name"]; + NSString* room = [itemId lowercaseString]; + NSString* nick = [data[itemId] findFirst:@"{urn:xmpp:bookmarks:1}conference/nick#"]; + //ignore password protected mucs + if([data[itemId] check:@"{urn:xmpp:bookmarks:1}conference/password"]) + continue; + NSNumber* autojoin = [data[itemId] findFirst:@"{urn:xmpp:bookmarks:1}conference@autojoin|bool"]; + if(autojoin == nil) + autojoin = @NO; //default value specified in xep + + //check if this is a new entry with autojoin=true + if(![ownFavorites containsObject:room] && [autojoin boolValue]) + { + DDLogInfo(@"Entering muc '%@' on account %@ because it got added to bookmarks...", room, account.accountID); + //make sure we update our favorites table right away, to counter any race conditions when joining multiple mucs with one bookmarks update + if(nick == nil) + nick = [account.mucProcessor calculateNickForMuc:room]; + //this will record the desired nickname: the mucProcessor will pick that up and use it to join the muc + [[DataLayer sharedInstance] addMucFavorite:room forAccountID:account.accountID andMucNick:nick]; + //try to join muc, but don't perform a bookmarks update (this muc came in through a bookmark already) + [account.mucProcessor sendDiscoQueryFor:room withJoin:YES andBookmarksUpdate:NO]; + } + //check if it is a known entry that changed autojoin to false + else if([ownFavorites containsObject:room] && ![autojoin boolValue]) + { + DDLogInfo(@"Leaving muc '%@' on account %@ because not listed as autojoin=true in bookmarks...", room, account.accountID); + //delete local favorites entry and leave room afterwards, but keep buddylist entry because only the autojoin flag changed + [account.mucProcessor leave:room withBookmarksUpdate:NO keepBuddylistEntry:YES]; + } + //check for nickname changes + else if([ownFavorites containsObject:room] && nick != nil) + { + NSString* oldNick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:account.accountID]; + if(![nick isEqualToString:oldNick]) + { + DDLogInfo(@"Updating muc '%@' nick on account %@ in database to nick provided by bookmarks: '%@'...", room, account.accountID, nick); + + //update muc nickname in database + [[DataLayer sharedInstance] updateOwnNickName:nick forMuc:room forAccount:account.accountID]; + [[DataLayer sharedInstance] addMucFavorite:room forAccountID:account.accountID andMucNick:nick]; //this will upate the already existing favorites entry + + //rejoin the muc (e.g. change nick) + //we don't have to do a full disco because we are sure this is a real muc and we are joined already + //(only real mucs are part of our local favorites list and this list is joined automatically) + [account.mucProcessor sendJoinPresenceFor:room]; + } + } + } + } + else if([type isEqualToString:@"retract"]) + { + for(NSString* itemId in data) + { + NSString* room = [itemId lowercaseString]; + if([ownFavorites containsObject:room]) + { + DDLogInfo(@"Leaving muc '%@' on account %@ because not listed in bookmarks anymore...", room, account.accountID); + //delete local favorites entry and leave room afterwards + [account.mucProcessor leave:room withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + else + DDLogVerbose(@"Ignoring retracted bookmark because not listed in muc_favorites already..."); + } + } + else + { + //deleted/purged node (e.g. all bookmarks deleted) + //--> remove and leave all mucs + for(NSString* room in ownFavorites) + { + DDLogInfo(@"Leaving muc '%@' on account %@ because all bookmarks got deleted...", room, account.accountID); + //delete local favorites entry and leave room afterwards + [account.mucProcessor leave:room withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + } +$$ + +$$class_handler(handleBookmarks2FetchResult, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $_ID((NSDictionary*), data)) + if(!account.connectionProperties.supportsBookmarksCompat) + { + DDLogWarn(@"Ignoring new XEP-0402 bookmarks, server does not support syncing between XEP-0048 and XEP-0402!"); + return; + } + + if(!success) + { + //item-not-found means: no bookmarks in storage --> use an empty data dict + if([errorIq check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}item-not-found"]) + data = @{}; + else + { + DDLogWarn(@"Could not fetch bookmarks from pep prior to publishing!"); + [self handleErrorWithDescription:NSLocalizedString(@"Failed to save groupchat bookmarks", @"") andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:YES]; + return; + } + } + + NSString* max_items = @"255"; //fallback for servers not supporting "max" + if(account.connectionProperties.supportsPubSubMax) + max_items = @"max"; + NSDictionary* infoDict = [[NSBundle mainBundle] infoDictionary]; + + NSSet* ownFavorites = [[DataLayer sharedInstance] listMucsForAccount:account.accountID]; + DDLogVerbose(@"Own favorites: %@", ownFavorites); + + //filter passwort protected mucs and make sure jids (the item ids) are always lowercase + NSMutableDictionary* _data = [NSMutableDictionary new]; + for(NSString* itemId in data) + { + if([data[itemId] check:@"{urn:xmpp:bookmarks:1}conference/password"]) + { + DDLogVerbose(@"Not copying muc %@ to bookmark data: password protected", itemId); + continue; + } + _data[[itemId lowercaseString]] = data[itemId]; + } + DDLogVerbose(@"Mucs listed in bookmarks2: %@", [_data allKeys]); + + //handle all changes of existing bookmarks + for(NSString* room in _data) + { + MLXMLNode* item = _data[room]; + + //we ignore the conference name (the name will be taken from the muc itself) + //NSString* name = [_data[room] findFirst:@"{urn:xmpp:bookmarks:1}conference@name"]; + //NSString* nick = [_data[room] findFirst:@"{urn:xmpp:bookmarks:1}conference/nick#"]; + NSNumber* autojoin = [item findFirst:@"{urn:xmpp:bookmarks:1}conference@autojoin|bool"]; + if(autojoin == nil) + autojoin = @NO; //default value specified in xep + + //check if the bookmark exists with autojoin==false and only update the autojoin and nick values, if true + if([ownFavorites containsObject:room] && ![autojoin boolValue]) + { + DDLogInfo(@"Updating autojoin of bookmarked muc '%@' on account %@ to 'true'...", room, account.accountID); + + //add or update nickname + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:account.accountID]; + if(nick != nil) + { + if(![item check:@"{urn:xmpp:bookmarks:1}conference/nick"]) + [[item findFirst:@"{urn:xmpp:bookmarks:1}conference"] addChildNode:[[MLXMLNode alloc] initWithElement:@"nick"]]; + ((MLXMLNode*)[item findFirst:@"{urn:xmpp:bookmarks:1}conference/nick"]).data = nick; + } + + //update autojoin value to true + ((MLXMLNode*)[item findFirst:@"{urn:xmpp:bookmarks:1}conference"]).attributes[@"autojoin"] = @"true"; + + //publish this bookmark item again + [account.pubsub publishItem:item onNode:@"urn:xmpp:bookmarks:1" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"whitelist", + @"pubsub#max_items": max_items, + } andHandler:$newHandler(self, bookmarks2Published, $ID(room))]; + } + } + + //add all mucs not yet listed in bookmarks + NSMutableSet* toAdd = [ownFavorites mutableCopy]; + [toAdd minusSet:[NSSet setWithArray:[_data allKeys]]]; + for(NSString* room in toAdd) + { + DDLogInfo(@"Adding muc '%@' on account %@ to bookmarks...", room, account.accountID); + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:account.accountID]; + [account.pubsub publishItem: + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{@"id": room} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"conference" andNamespace:@"urn:xmpp:bookmarks:1" withAttributes:@{ + @"autojoin": @"true", + } andChildren:@[ + nilWrapper(nick != nil ? [[MLXMLNode alloc] initWithElement:@"nick" withAttributes:@{} andChildren:@[] andData:nick] : nil), + [[MLXMLNode alloc] initWithElement:@"extensions" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"added-by" andNamespace:@"urn:monal.im:bookmarks:info" withAttributes:@{ + @"name": @"Monal", + @"version": infoDict[@"CFBundleShortVersionString"], + @"build": infoDict[@"CFBundleVersion"], + } andChildren:@[] andData:nil] + ] andData:nil] + ]andData:nil] + ] andData:nil] + onNode:@"urn:xmpp:bookmarks:1" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"whitelist", + @"pubsub#max_items": max_items, + } andHandler:$newHandler(self, bookmarks2Published, $ID(room))]; + } + + //remove all mucs not listed in local favorites table + NSMutableSet* toRemove = [NSMutableSet setWithArray:[_data allKeys]]; + [toRemove minusSet:ownFavorites]; + for(NSString* room in toRemove) + { + DDLogInfo(@"Removing muc '%@' on account %@ from bookmarks...", room, account.accountID); + [account.pubsub retractItemWithId:room onNode:@"urn:xmpp:bookmarks:1" andHandler:$newHandler(self, bookmarks2Retracted, $ID(room))]; + } +$$ + +$$class_handler(bookmarks2Published, $$ID(xmpp*, account), $$ID(NSString*, room), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + if(!account.connectionProperties.supportsBookmarksCompat) + { + DDLogWarn(@"Ignoring new XEP-0402 bookmarks, server does not support syncing between XEP-0048 and XEP-0402!"); + return; + } + + if(!success) + { + DDLogWarn(@"Could not publish bookmark for muc '%@' to pep!", room); + [self handleErrorWithDescription:[NSString stringWithFormat:NSLocalizedString(@"Failed to save bookmark for Group/Channel: %@", @""), room] andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:YES]; + return; + } + DDLogDebug(@"Published bookmark for muc '%@' to pep", room); +$$ + +$$class_handler(bookmarks2Retracted, $$ID(xmpp*, account), $$ID(NSString*, room), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + if(!account.connectionProperties.supportsBookmarksCompat) + { + DDLogWarn(@"Ignoring new XEP-0402 bookmarks, server does not support syncing between XEP-0048 and XEP-0402!"); + return; + } + + if(!success) + { + DDLogWarn(@"Could not retract bookmark for muc '%@' from pep!", room); + [self handleErrorWithDescription:[NSString stringWithFormat:NSLocalizedString(@"Failed to remove bookmark for Group/Channel: %@", @""), room] andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:YES]; + return; + } + DDLogDebug(@"Retracted bookmark for muc '%@' from pep", room); +$$ + +$$class_handler(bookmarksHandler, $$ID(xmpp*, account), $$ID(NSString*, jid), $$ID(NSString*, type), $_ID((NSDictionary*), data)) + if(account.connectionProperties.supportsBookmarksCompat) + { + DDLogInfo(@"Ignoring old XEP-0048 bookmarks, server supports syncing between XEP-0048 and XEP-0402..."); + return; + } + + if(![jid isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring bookmarks update not coming from our own jid"); + return; + } + + NSSet* ownFavorites = [[DataLayer sharedInstance] listMucsForAccount:account.accountID]; + + //new/updated bookmarks + if([type isEqualToString:@"publish"]) + { + for(NSString* itemId in data) + { + //iterate through all conference elements provided + NSMutableSet* bookmarkedMucs = [NSMutableSet new]; + for(MLXMLNode* conference in [data[itemId] find:@"{storage:bookmarks}storage/conference"]) + { + //we ignore the conference name (the name will be taken from the muc itself) + //NSString* name = [conference findFirst:@"/@name"]; + NSString* room = [[conference findFirst:@"/@jid"] lowercaseString]; + //ignore non-xep-compliant entries + if(!room) + { + DDLogError(@"Received non-xep-compliant bookmarks entry, ignoring: %@", conference); + continue; + } + + //ignore password protected mucs + if([conference check:@"password"]) + continue; + + [bookmarkedMucs addObject:room]; + NSString* nick = [conference findFirst:@"nick#"]; + NSNumber* autojoin = [conference findFirst:@"/@autojoin|bool"]; + if(autojoin == nil) + autojoin = @NO; //default value specified in xep + + //check if this is a new entry with autojoin=true + if(![ownFavorites containsObject:room] && [autojoin boolValue]) + { + DDLogInfo(@"Entering muc '%@' on account %@ because it got added to bookmarks...", room, account.accountID); + //make sure we update our favorites table right away, to counter any race conditions when joining multiple mucs with one bookmarks update + if(nick == nil) + nick = [account.mucProcessor calculateNickForMuc:room]; + //this will record the desired nickname: the mucProcessor will pick that up and use it to join the muc + [[DataLayer sharedInstance] addMucFavorite:room forAccountID:account.accountID andMucNick:nick]; + //try to join muc, but don't perform a bookmarks update (this muc came in through a bookmark already) + [account.mucProcessor sendDiscoQueryFor:room withJoin:YES andBookmarksUpdate:NO]; + } + //check if it is a known entry that changed autojoin to false + else if([ownFavorites containsObject:room] && ![autojoin boolValue]) + { + DDLogInfo(@"Leaving muc '%@' on account %@ because not listed as autojoin=true in bookmarks...", room, account.accountID); + //delete local favorites entry and leave room afterwards, but keep buddylist entry because only the autojoin flag changed + [account.mucProcessor leave:room withBookmarksUpdate:NO keepBuddylistEntry:YES]; + } + //check for nickname changes + else if([ownFavorites containsObject:room] && nick != nil) + { + NSString* oldNick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:account.accountID]; + if(![nick isEqualToString:oldNick]) + { + DDLogInfo(@"Updating muc '%@' nick on account %@ in database to nick provided by bookmarks: '%@'...", room, account.accountID, nick); + + //update muc nickname in database + [[DataLayer sharedInstance] updateOwnNickName:nick forMuc:room forAccount:account.accountID]; + [[DataLayer sharedInstance] addMucFavorite:room forAccountID:account.accountID andMucNick:nick]; //this will upate the already existing favorites entry + + //rejoin the muc (e.g. change nick) + //we don't have to do a full disco because we are sure this is a real muc and we are joined already + //(only real mucs are part of our local favorites list and this list is joined automatically) + [account.mucProcessor sendJoinPresenceFor:room]; + } + } + } + + //remove and leave all mucs removed from bookmarks + NSMutableSet* toLeave = [ownFavorites mutableCopy]; + [toLeave minusSet:bookmarkedMucs]; + for(NSString* room in toLeave) + { + DDLogInfo(@"Leaving muc '%@' on account %@ because not listed in bookmarks anymore...", room, account.accountID); + //delete local favorites entry and leave room afterwards + [account.mucProcessor leave:room withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } + + return; //we only need the first pep item (there should be only one item in the first place) + } + //FALLTHROUGH to "delete all" if no item was found + } + //deleted/purged node or retracted item (e.g. all bookmarks deleted) + //--> remove and leave all mucs + for(NSString* room in ownFavorites) + { + DDLogInfo(@"Leaving muc '%@' on account %@ because all bookmarks got deleted...", room, account.accountID); + //delete local favorites entry and leave room afterwards + [account.mucProcessor leave:room withBookmarksUpdate:NO keepBuddylistEntry:NO]; + } +$$ + +$$class_handler(handleBookarksFetchResult, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $_ID((NSDictionary*), data)) + if(account.connectionProperties.supportsBookmarksCompat) + { + DDLogInfo(@"Ignoring old XEP-0048 bookmarks, server supports syncing between XEP-0048 and XEP-0402..."); + return; + } + + if(!success) + { + //item-not-found means: no bookmarks in storage --> use an empty data dict + if([errorIq check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}item-not-found"]) + data = @{}; + else + { + DDLogWarn(@"Could not fetch bookmarks from pep prior to publishing!"); + [self handleErrorWithDescription:NSLocalizedString(@"Failed to save groupchat bookmarks", @"") andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:YES]; + return; + } + } + + BOOL changed = NO; + NSSet* ownFavorites = [[DataLayer sharedInstance] listMucsForAccount:account.accountID]; + + for(NSString* itemId in data) + { + //ignore non-xep-compliant data and continue as if no data was received at all + if(![data[itemId] check:@"{storage:bookmarks}storage"]) + { + DDLogError(@"Received non-xep-compliant bookmarks data: %@", data); + break; + } + + NSMutableSet* bookmarkedMucs = [NSMutableSet new]; + for(MLXMLNode* conference in [data[itemId] find:@"{storage:bookmarks}storage/conference"]) + { + //we ignore the conference name (the name will be taken from the muc itself) + //NSString* name = [conference findFirst:@"/@name"]; + NSString* room = [[conference findFirst:@"/@jid"] lowercaseString]; + //ignore non-xep-compliant entries + if(!room) + { + DDLogError(@"Received non-xep-compliant bookmarks entry, ignoring: %@", conference); + continue; + } + [bookmarkedMucs addObject:room]; + NSNumber* autojoin = [conference findFirst:@"/@autojoin|bool"]; + if(autojoin == nil) + autojoin = @NO; //default value specified in xep + + //check if the bookmark exists with autojoin==false and only update the autojoin and nick values, if true + if([ownFavorites containsObject:room] && ![autojoin boolValue]) + { + DDLogInfo(@"Updating autojoin of bookmarked muc '%@' on account %@ to 'true'...", room, account.accountID); + + //add or update nickname + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:account.accountID]; + if(nick != nil) + { + if(![conference check:@"nick"]) + [conference addChildNode:[[MLXMLNode alloc] initWithElement:@"nick"]]; + ((MLXMLNode*)[conference findFirst:@"nick"]).data = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:account.accountID]; + } + + //update autojoin value to true + conference.attributes[@"autojoin"] = @"true"; + changed = YES; + } + } + + //add all mucs not yet listed in bookmarks + NSMutableSet* toAdd = [ownFavorites mutableCopy]; + [toAdd minusSet:bookmarkedMucs]; + for(NSString* room in toAdd) + { + DDLogInfo(@"Adding muc '%@' on account %@ to bookmarks...", room, account.accountID); + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:account.accountID]; + [[data[itemId] findFirst:@"{storage:bookmarks}storage"] addChildNode:[[MLXMLNode alloc] initWithElement:@"conference" withAttributes:@{ + @"jid": room, + @"name": [[MLContact createContactFromJid:room andAccountID:account.accountID] contactDisplayName], + @"autojoin": @"true", + } andChildren:(nick != nil ? @[[[MLXMLNode alloc] initWithElement:@"nick" withAttributes:@{} andChildren:@[] andData:nick]] : @[]) andData:nil]]; + changed = YES; + } + + //remove all mucs not listed in local favorites table + NSMutableSet* toRemove = [bookmarkedMucs mutableCopy]; + [toRemove minusSet:ownFavorites]; + for(NSString* room in toRemove) + { + DDLogInfo(@"Removing muc '%@' on account %@ from bookmarks...", room, account.accountID); + [[data[itemId] findFirst:@"{storage:bookmarks}storage"] removeChildNode:[data[itemId] findFirst:@"{storage:bookmarks}storage/conference", room]]; + changed = YES; + } + + //publish new bookmarks if something was changed + if(changed) + [account.pubsub publishItem:data[itemId] onNode:@"storage:bookmarks" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"whitelist" + } andHandler:$newHandler(self, bookmarksPublished)]; + + //we only need the first pep item (there should be only one item in the first place) + return; + } + + //don't publish an empty bookmarks node if there is nothing to publish at all + if([ownFavorites count] == 0) + { + DDLogInfo(@"neither a pep item was found, nor do we have any local muc favorites: don't publish anything"); + return; + } + + DDLogInfo(@"no pep item was found: publish our bookmarks the first time"); + NSMutableArray* conferences = [NSMutableArray new]; + for(NSString* room in ownFavorites) + { + DDLogInfo(@"Adding muc '%@' on account %@ to bookmarks...", room, account.accountID); + NSString* nick = [[DataLayer sharedInstance] ownNickNameforMuc:room forAccount:account.accountID]; + [conferences addObject:[[MLXMLNode alloc] initWithElement:@"conference" withAttributes:@{ + @"jid": room, + @"name": [[MLContact createContactFromJid:room andAccountID:account.accountID] contactDisplayName], + @"autojoin": @"true", + } andChildren:(nick != nil ? @[[[MLXMLNode alloc] initWithElement:@"nick" withAttributes:@{} andChildren:@[] andData:nick]] : @[]) andData:nil]]; + } + [account.pubsub publishItem: + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{@"id": @"current"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"storage" andNamespace:@"storage:bookmarks" withAttributes:@{} andChildren:conferences andData:nil] + ] andData:nil] + onNode:@"storage:bookmarks" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"whitelist" + } andHandler:$newHandler(self, bookmarksPublished)]; +$$ + +$$class_handler(bookmarksPublished, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + if(account.connectionProperties.supportsBookmarksCompat) + { + DDLogInfo(@"Ignoring old XEP-0048 bookmarks, server supports syncing between XEP-0048 and XEP-0402..."); + return; + } + + if(!success) + { + DDLogWarn(@"Could not publish bookmarks to pep!"); + [self handleErrorWithDescription:NSLocalizedString(@"Failed to save groupchat bookmarks", @"") andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:YES]; + return; + } + DDLogDebug(@"Published bookmarks to pep"); +$$ + +$$class_handler(rosterNamePublished, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + if(!success) + { + DDLogWarn(@"Could not publish roster name to pep!"); + [self handleErrorWithDescription:NSLocalizedString(@"Failed to publish own nickname", @"") andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:NO]; + return; + } + DDLogDebug(@"Published roster name to pep"); +$$ + +$$class_handler(rosterNameDeleted, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + if(!success) + { + //item-not-found means: nick already deleted --> ignore this error + if([errorIq check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}item-not-found"]) + { + DDLogWarn(@"Roster name was already deleted from pep, ignoring error!"); + return; + } + DDLogWarn(@"Could not remove roster name from pep!"); + [self handleErrorWithDescription:NSLocalizedString(@"Failed to delete own nickname", @"") andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:NO]; + return; + } + DDLogDebug(@"Removed roster name from pep"); +$$ + +$$class_handler(avatarDeleted, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + if(!success) + { + //item-not-found means: avatar already deleted --> ignore this error + if([errorIq check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}item-not-found"]) + { + DDLogWarn(@"Avatar image was already deleted from pep, ignoring error!"); + return; + } + DDLogWarn(@"Could not delete avatar image from pep!"); + [self handleErrorWithDescription:NSLocalizedString(@"Failed to delete own avatar", @"") andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:NO]; + return; + } + DDLogDebug(@"Removed avatar from pep"); +$$ + +$$class_handler(avatarMetadataPublished, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason)) + if(!success) + { + DDLogWarn(@"Could not publish avatar metadata to pep!"); + [self handleErrorWithDescription:NSLocalizedString(@"Failed to publish own avatar", @"") andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:NO]; + return; + } + DDLogDebug(@"Published avatar metadata to pep"); +$$ + +$$class_handler(avatarDataPublished, $$ID(xmpp*, account), $$BOOL(success), $_ID(XMPPIQ*, errorIq), $_ID(NSString*, errorReason), $$ID(NSString*, imageHash), $$UINTEGER(imageBytesLen)) + if(!success) + { + DDLogWarn(@"Could not publish avatar image data for hash %@!", imageHash); + [self handleErrorWithDescription:NSLocalizedString(@"Failed to publish own avatar", @"") andAccount:account andErrorIq:errorIq andErrorReason:errorReason andIsSevere:NO]; + return; + } + + DDLogInfo(@"Avatar image data for hash %@ published successfully, now publishing metadata", imageHash); + + //publish metadata node (must be done *after* publishing the new data node) + [account.pubsub publishItem: + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{@"id": imageHash} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"metadata" andNamespace:@"urn:xmpp:avatar:metadata" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"info" withAttributes:@{ + @"id": imageHash, + @"type": @"image/jpeg", + @"bytes": [NSString stringWithFormat:@"%lu", (unsigned long)imageBytesLen] + } andChildren:@[] andData:nil] + ] andData:nil] + ] andData:nil] + onNode:@"urn:xmpp:avatar:metadata" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"presence" + } andHandler:$newHandler(self, avatarMetadataPublished)]; +$$ + ++(void) handleErrorWithDescription:(NSString*) description andAccount:(xmpp*) account andErrorIq:(XMPPIQ*) errorIq andErrorReason:(NSString*) errorReason andIsSevere:(BOOL) isSevere +{ + MLAssert(errorIq || errorReason, @"at least one of errorIq or errorReason must be set when calling error handler!"); + if(errorIq) + [HelperTools postError:description withNode:errorIq andAccount:account andIsSevere:isSevere]; + else if(errorReason) + [HelperTools postError:[NSString stringWithFormat:@"%@: %@", description, errorReason] withNode:nil andAccount:account andIsSevere:isSevere]; +} + +@end diff --git a/Monal/Classes/MLQRCodeScanner.swift b/Monal/Classes/MLQRCodeScanner.swift new file mode 100644 index 0000000..db5f92e --- /dev/null +++ b/Monal/Classes/MLQRCodeScanner.swift @@ -0,0 +1,284 @@ +// +// MLQRCodeScanner.swift +// Monal +// +// Created by Friedrich Altheide on 20.11.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +import SafariServices + +@objc protocol MLLQRCodeScannerAccountLoginDelegate : AnyObject +{ + func MLQRCodeAccountLoginScanned(jid: String, password: String) + func closeQRCodeScanner() +} + +struct XMPPLoginQRCode : Codable +{ + let usedProtocol:String + let address:String + let password:String + + private enum CodingKeys: String, CodingKey + { + case usedProtocol = "protocol", address, password + } +} + +@objc class MLQRCodeScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate +{ + @objc weak var loginDelegate : MLLQRCodeScannerAccountLoginDelegate? + + var videoPreviewLayer: AVCaptureVideoPreviewLayer!; + var captureSession: AVCaptureSession!; + + override func viewDidLoad() + { + super.viewDidLoad() + self.title = NSLocalizedString("QR-Code Scanner", comment: "") + view.backgroundColor = UIColor.black + + switch AVCaptureDevice.authorizationStatus(for: .video) + { + case .authorized: + self.setupCaptureSession() + + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + if granted { + DispatchQueue.main.async { + self.setupCaptureSession() + } + } + } + + case .denied: + return + + case .restricted: + return + + @unknown default: + return; + } + } + + override func viewWillAppear(_ animated: Bool) + { + super.viewWillAppear(animated) + + if (captureSession?.isRunning == false) + { + captureSession.startRunning() + } + } + + override func viewWillDisappear(_ animated: Bool) + { + if (captureSession?.isRunning == true) + { + captureSession.stopRunning() + } + super.viewWillDisappear(animated) + } + + func setupCaptureSession() + { + // init capture session + captureSession = AVCaptureSession() + guard let captureDevice = AVCaptureDevice.default(for: .video) + else + { + errorMsg(title: NSLocalizedString("QR-Code video error", comment: "QR-Code-Scanner"), msg: NSLocalizedString("Could not get default capture device", comment: "QR-Code-Scanner")) + return; + } + let videoInput: AVCaptureDeviceInput + + do + { + videoInput = try AVCaptureDeviceInput(device: captureDevice) + } catch + { + errorMsg(title: NSLocalizedString("QR-Code video error", comment: "QR-Code-Scanner"), msg: NSLocalizedString("Could not init video session", comment: "QR-Code-Scanner")) + return + } + if(captureSession.canAddInput(videoInput)) + { + captureSession.addInput(videoInput) + } + else + { + errorMsgNoCameraFound() + return; + } + let metadataOutput = AVCaptureMetadataOutput() + + if (captureSession.canAddOutput(metadataOutput)) { + captureSession.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [.qr] + } else { + errorMsgNoCameraFound() + return + } + + videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + videoPreviewLayer.frame = view.layer.bounds + videoPreviewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(videoPreviewLayer) + + captureSession.startRunning() + } + + func errorMsgNoCameraFound() + { + captureSession = nil + + errorMsg(title: NSLocalizedString("Could not access camera", comment: "QR-Code-Scanner: camera not found"), msg: NSLocalizedString("It does not seem as your device has a camera. Please use a device with a camera for scanning", comment: "QR-Code-Scanner: Camera not found")) + } + + override var prefersStatusBarHidden: Bool + { + return false + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask + { + return .portrait + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + captureSession.stopRunning() + + if let metadataObject = metadataObjects.first { + guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } + + guard let qrCodeAsString = readableObject.stringValue else { + return handleQRCodeError() + } + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + + //open https?:// urls in safari view controller just as they would if the qrcode was scanned using the camera app + if qrCodeAsString.hasPrefix("https://") || qrCodeAsString.hasPrefix("http://") { + if let url = URL(string:qrCodeAsString) { + let vc = SFSafariViewController(url:url, configuration:SFSafariViewController.Configuration()) + present(vc, animated: true) + } + //let our app delegate handle all xmpp: urls + } else if qrCodeAsString.hasPrefix("xmpp:") { + guard let url = URL(string:qrCodeAsString) else { + return handleQRCodeError() + } + return (UIApplication.shared.delegate as! MonalAppDelegate).handleXMPPURL(url) + //if none of the above: handle json provisioning qrcodes, see: https://github.com/iNPUTmice/Conversations/issues/3796 + } else { + // check if we have a json object + guard let qrCodeData = qrCodeAsString.data(using:.utf8) else { + return handleQRCodeError() + } + let jsonDecoder = JSONDecoder() + do { + let loginData = try jsonDecoder.decode(XMPPLoginQRCode.self, from:qrCodeData) + handleAccountLogin(loginData:loginData) + } catch { + handleQRCodeError() + } + return + } + } + } + + func errorMsg(title: String, msg: String, startCaptureOnClose: Bool = false) + { + let ac = UIAlertController(title: title, message: msg, preferredStyle: .alert) + ac.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: ""), style: .default) + { + action -> Void in + // start capture again after invalid qr code + if(startCaptureOnClose == true) + { + self.captureSession.startRunning() + } + else if (self.loginDelegate != nil) { + self.loginDelegate?.closeQRCodeScanner() + } + } + ) + DispatchQueue.main.async{ + self.present(ac, animated: true) + } + } + + func handleAccountLogin(loginData: XMPPLoginQRCode) + { + if(loginData.usedProtocol == "xmpp") + { + if(self.loginDelegate != nil) + { + self.navigationController?.popViewController(animated: true) + self.loginDelegate?.MLQRCodeAccountLoginScanned(jid: loginData.address, password: loginData.password) + } + else + { + errorMsg(title: NSLocalizedString("Wrong menu", comment: "QR-Code-Scanner: account scan wrong menu"), msg: NSLocalizedString("The qrcode contains login credentials for an acount. Go to settings -> new account and rescan the qrcode", comment: "QR-Code-Scanner: account scan wrong menu"), startCaptureOnClose: true) + } + } + } + + func handleQRCodeError() + { + errorMsg(title: NSLocalizedString("Invalid format", comment: "QR-Code-Scanner: invalid format"), msg: NSLocalizedString("We could not find a xmpp related QR-Code", comment: "QR-Code-Scanner: invalid format"), startCaptureOnClose: true) + } +} + +struct MLQRCodeScanner : UIViewControllerRepresentable { + let handleLogin: ((String, String) -> Void)? + let handleClose: (() -> Void) + + class Coordinator: NSObject, MLLQRCodeScannerAccountLoginDelegate { + let handleLogin: ((String, String) -> Void)? + let handleClose: (() -> Void) + + func MLQRCodeAccountLoginScanned(jid: String, password: String) { + if(self.handleLogin != nil) { + self.handleLogin!(jid, password) + } + } + + func closeQRCodeScanner() { + self.handleClose() + } + + init(handleLogin: ((String, String) -> Void)?, handleClose: @escaping () -> Void) { + self.handleLogin = handleLogin + self.handleClose = handleClose + } + } + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> MLQRCodeScannerController { + let qrCodeScannerViewController = MLQRCodeScannerController() + if(self.handleLogin != nil) { + qrCodeScannerViewController.loginDelegate = context.coordinator + } + return qrCodeScannerViewController + } + + func updateUIViewController(_ uiViewController: MLQRCodeScannerController, context: UIViewControllerRepresentableContext) { + } + + func makeCoordinator() -> MLQRCodeScanner.Coordinator { + Coordinator(handleLogin: self.handleLogin, handleClose: self.handleClose); + } + + init(handleClose: @escaping () -> Void) { + self.handleLogin = nil + self.handleClose = handleClose + } + + init(handleLogin: @escaping (String, String) -> Void, handleClose: @escaping () -> Void) { + self.handleLogin = handleLogin + self.handleClose = handleClose + } +} diff --git a/Monal/Classes/MLReloadCell.h b/Monal/Classes/MLReloadCell.h new file mode 100644 index 0000000..35a11aa --- /dev/null +++ b/Monal/Classes/MLReloadCell.h @@ -0,0 +1,19 @@ +// +// MLReloadCell.h +// Monal +// +// Created by Friedrich Altheide on 09.10.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLBaseCell.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLReloadCell : MLBaseCell +@property (weak, nonatomic) IBOutlet UILabel* reloadLabel; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLReloadCell.m b/Monal/Classes/MLReloadCell.m new file mode 100644 index 0000000..2816fdc --- /dev/null +++ b/Monal/Classes/MLReloadCell.m @@ -0,0 +1,13 @@ +// +// MLReloadCell.m +// Monal +// +// Created by Friedrich Altheide on 09.10.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLReloadCell.h" + +@implementation MLReloadCell + +@end diff --git a/Monal/Classes/MLResizingTextView.h b/Monal/Classes/MLResizingTextView.h new file mode 100644 index 0000000..9a9f9d4 --- /dev/null +++ b/Monal/Classes/MLResizingTextView.h @@ -0,0 +1,14 @@ +// +// MLResizingTextView.h +// Monal +// +// Created by Anurodh Pokharel on 2/2/16. +// Copyright © 2016 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" + +@interface MLResizingTextView : UITextView + +@end diff --git a/Monal/Classes/MLResizingTextView.m b/Monal/Classes/MLResizingTextView.m new file mode 100644 index 0000000..9513833 --- /dev/null +++ b/Monal/Classes/MLResizingTextView.m @@ -0,0 +1,45 @@ +// +// MLResizingTextView.m +// Monal +// +// Created by Anurodh Pokharel on 2/2/16. +// Copyright © 2016 Monal.im. All rights reserved. +// + +#import "MLResizingTextView.h" +#import "HelperTools.h" + +@implementation MLResizingTextView + +- (void) layoutSubviews +{ + [super layoutSubviews]; + + if (!CGSizeEqualToSize(self.bounds.size, [self intrinsicContentSize])) { + [self invalidateIntrinsicContentSize]; + } + if([[HelperTools defaultsDB] boolForKey: @"showKeyboardOnChatOpen"]) + [self becomeFirstResponder]; +} + +- (CGSize)intrinsicContentSize +{ + CGSize intrinsicContentSize = self.contentSize; + + intrinsicContentSize.width += (self.textContainerInset.left + self.textContainerInset.right ) / 2.0f; + // intrinsicContentSize.height += (self.textContainerInset.top + self.textContainerInset.bottom) / 2.0f; + + return intrinsicContentSize; +} + +-(NSArray*) keyCommands +{ + UIKeyCommand* const tabCommand = [UIKeyCommand keyCommandWithInput: @"\t" modifierFlags: 0 action:@selector(ignore)]; + return @[tabCommand]; +} + +-(void) ignore +{ +} + +@end diff --git a/Monal/Classes/MLSQLite.h b/Monal/Classes/MLSQLite.h new file mode 100644 index 0000000..daaacd8 --- /dev/null +++ b/Monal/Classes/MLSQLite.h @@ -0,0 +1,49 @@ +// +// MLSQLite.h +// Monal +// +// Created by Thilo Molitor on 31.07.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef id _Nullable (^monal_sqlite_operations_t)(void); +typedef BOOL (^monal_sqlite_bool_operations_t)(void); + +@interface MLSQLite : NSObject + ++(id) sharedInstanceForFile:(NSString*) dbFile; + +-(void) voidReadTransaction:(monal_void_block_t) operations; +-(BOOL) boolReadTransaction:(monal_sqlite_bool_operations_t) operations; +-(id) idReadTransaction:(monal_sqlite_operations_t) operations; + +-(void) voidWriteTransaction:(monal_void_block_t) operations; +-(BOOL) boolWriteTransaction:(monal_sqlite_bool_operations_t) operations; +-(id) idWriteTransaction:(monal_sqlite_operations_t) operations; + +-(id _Nullable) executeScalar:(NSString*) query; +-(id _Nullable) executeScalar:(NSString*) query andArguments:(NSArray*) args; + +-(NSArray* _Nullable) executeScalarReader:(NSString*) query; +-(NSArray* _Nullable) executeScalarReader:(NSString*) query andArguments:(NSArray*) args; + +-(NSMutableArray* _Nullable) executeReader:(NSString*) query; +-(NSMutableArray* _Nullable) executeReader:(NSString*) query andArguments:(NSArray*) args; + +-(BOOL) executeNonQuery:(NSString*) query; +-(BOOL) executeNonQuery:(NSString*) query andArguments:(NSArray *) args; + +-(NSNumber*) lastInsertId; + +-(void) checkpointWal; +-(void) enableWAL; +-(void) vacuum; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLSQLite.m b/Monal/Classes/MLSQLite.m new file mode 100644 index 0000000..386ed8f --- /dev/null +++ b/Monal/Classes/MLSQLite.m @@ -0,0 +1,683 @@ +// +// MLSQLite.m +// Monal +// +// Created by Thilo Molitor on 31.07.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import +#import "MLSQLite.h" +#import "HelperTools.h" + +@interface MLSQLite() +{ + NSString* _dbFile; + sqlite3* _database; +} +@end + +static NSMutableDictionary* currentTransactions; + +@implementation MLSQLite + ++(void) initialize +{ + currentTransactions = [NSMutableDictionary new]; + + if(sqlite3_config(SQLITE_CONFIG_MULTITHREAD) == SQLITE_OK) + DDLogInfo(@"sqlite initialize: sqlite3 configured ok"); + else + { + DDLogError(@"sqlite initialize: sqlite3 not configured ok"); + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"sqlite3_config() failed" userInfo:nil]; + } + + sqlite3_initialize(); + DDLogInfo(@"sqlite initialize: using mysql lib version: %s", sqlite3_libversion()); +} + +//every thread gets its own instance having its own db connection +//this allows for concurrent reads/writes ++(id) sharedInstanceForFile:(NSString*) dbFile +{ + MLAssert(dbFile != nil, @"MLSQLite sharedInstanceForFile:nil: file MUST NOT be nil!"); + @synchronized(self) { + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + if(threadData[@"_sqliteInstancesForThread"] && threadData[@"_sqliteInstancesForThread"][dbFile]) + return threadData[@"_sqliteInstancesForThread"][dbFile]; + MLSQLite* newInstance = [[self alloc] initWithFile:dbFile]; + //init dictionaries if neccessary + if(!threadData[@"_sqliteInstancesForThread"]) + threadData[@"_sqliteInstancesForThread"] = [NSMutableDictionary new]; + if(!threadData[@"_sqliteTransactionsRunning"]) + threadData[@"_sqliteTransactionsRunning"] = [NSMutableDictionary new]; + if(!threadData[@"_sqliteStartedReadTransaction"]) + threadData[@"_sqliteStartedReadTransaction"] = [NSMutableDictionary new]; + //save thread-local instance + threadData[@"_sqliteInstancesForThread"][dbFile] = newInstance; + //init data for nested transactions + threadData[@"_sqliteTransactionsRunning"][dbFile] = [NSNumber numberWithInt:0]; + threadData[@"_sqliteStartedReadTransaction"][dbFile] = @NO; + return newInstance; + } +} + +-(id) initWithFile:(NSString*) dbFile +{ + _dbFile = dbFile; + DDLogVerbose(@"db path %@", _dbFile); + + //mark all files to stay unlocked even if device gets locked again + [HelperTools configureFileProtectionFor:_dbFile]; + [HelperTools configureFileProtectionFor:[NSString stringWithFormat:@"%@-wal", _dbFile]]; + [HelperTools configureFileProtectionFor:[NSString stringWithFormat:@"%@-shm", _dbFile]]; + + if(sqlite3_open_v2([_dbFile UTF8String], &(self->_database), SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) == SQLITE_OK) + DDLogInfo(@"Database opened: %@", _dbFile); + else + { + //database error message + DDLogError(@"Error opening database: %@", _dbFile); + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"sqlite3_open_v2() failed" userInfo:nil]; + } + + //use this observer because dealloc will not be called in the same thread as the sqlite statements got prepared in + [[NSNotificationCenter defaultCenter] addObserverForName:NSThreadWillExitNotification object:[NSThread currentThread] queue:nil usingBlock:^(NSNotification* notification __unused) { + @synchronized(self) { + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + if([threadData[@"_sqliteTransactionsRunning"][self->_dbFile] intValue] > 1) + { + DDLogError(@"Transaction leak in NSThreadWillExitNotification: trying to close sqlite3 connection while transaction still open"); + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"Transaction leak in NSThreadWillExitNotification: trying to close sqlite3 connection while transaction still open" userInfo:threadData]; + } + if(self->_database) + { + DDLogInfo(@"Closing database in NSThreadWillExitNotification: %@", self->_dbFile); + sqlite3_close(self->_database); + self->_database = NULL; + } + } + }]; + + //some settings (e.g. truncate is faster than delete) + //this uses the private api because we have no thread local instance added to the threadData dictionary yet and we don't use a transaction either (and public apis check both) + //--> we must use the internal api because it does not call testThreadInstanceForQuery: testTransactionsForQuery: + sqlite3_busy_timeout(self->_database, 2000); //set the busy time as early as possible to make sure the pragma states don't trigger a retry too often + while([self executeNonQuery:@"PRAGMA synchronous=NORMAL;" andArguments:@[] withException:NO] != YES) + DDLogError(@"Database locked, while calling 'PRAGMA synchronous=NORMAL;', retrying..."); + while([self executeNonQuery:@"PRAGMA truncate;" andArguments:@[] withException:NO] != YES) + DDLogError(@"Database locked, while calling 'PRAGMA truncate;', retrying..."); + while([self executeNonQuery:@"PRAGMA foreign_keys=on;" andArguments:@[] withException:NO] != YES) + DDLogError(@"Database locked, while calling 'PRAGMA foreign_keys=on;', retrying..."); + //this seems to provide *slightly* better security + //see https://sqlite.org/pragma.html#pragma_trusted_schema + while([self executeNonQuery:@"PRAGMA trusted_schema = off;" andArguments:@[] withException:NO] != YES) + DDLogError(@"Database locked, while calling 'PRAGMA trusted_schema = off;', retrying..."); + + return self; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + @synchronized(self) { + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + if([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] > 1) + { + DDLogError(@"Transaction leak in dealloc: trying to close sqlite3 connection while transaction still open"); + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"Transaction leak in dealloc: trying to close sqlite3 connection while transaction still open" userInfo:threadData]; + } + if(self->_database) + { + DDLogInfo(@"Closing database in dealloc: %@", _dbFile); + sqlite3_close(self->_database); + self->_database = NULL; + } + } +} + +-(NSString*) calcThreadName +{ + __uint64_t tid; + if(pthread_threadid_np(NULL, &tid) == 0) + return [[NSString alloc] initWithFormat:@"%llu(%@) --> %@", tid, [NSThread currentThread].name, [NSThread currentThread]]; + else + return [[NSString alloc] initWithFormat:@"missing threadId (%@) --> %@", [NSThread currentThread].name, [NSThread currentThread]]; +} + +#pragma mark - private sql api + +-(sqlite3_stmt*) prepareQuery:(NSString*) query withArgs:(NSArray*) args +{ + sqlite3_stmt* statement; + + if(sqlite3_prepare_v2(self->_database, [query cStringUsingEncoding:NSUTF8StringEncoding], -1, &statement, NULL) != SQLITE_OK) + { + [self throwErrorForQuery:query andArguments:args]; + return NULL; + } + + if((int)args.count != sqlite3_bind_parameter_count(statement)) + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"SQL parameter count not equals argument count!" userInfo:@{ + @"query": query, + @"args": args, + @"paramCount": @(sqlite3_bind_parameter_count(statement)), + @"argCount": @(args.count), + }]; + + //bind args to statement + sqlite3_reset(statement); + [args enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop __unused) { + if([obj isKindOfClass:[NSNumber class]]) + { + NSNumber* number = (NSNumber*)obj; + if(sqlite3_bind_double(statement, (signed)idx+1, [number doubleValue]) != SQLITE_OK) + { + DDLogError(@"number bind error: %@", number); + [self throwErrorForQuery:query andArguments:args]; + } + } + else if([obj isKindOfClass:[NSString class]]) + { + NSString* text = (NSString*)obj; + if(sqlite3_bind_text(statement, (signed)idx+1, [text cStringUsingEncoding:NSUTF8StringEncoding], -1, SQLITE_TRANSIENT) != SQLITE_OK) + { + DDLogError(@"text bind error: %@", text); + [self throwErrorForQuery:query andArguments:args]; + } + } + else if([obj isKindOfClass:[NSData class]]) + { + NSData* data = (NSData*)obj; + if(sqlite3_bind_blob(statement, (signed)idx+1, [data bytes], (int)data.length, SQLITE_TRANSIENT) != SQLITE_OK) + { + DDLogError(@"blob bind error: %@", data); + [self throwErrorForQuery:query andArguments:args]; + } + } + else if([obj isKindOfClass:[NSNull class]]) + { + if(sqlite3_bind_null(statement, (signed)idx+1) != SQLITE_OK) + { + DDLogError(@"null bind error"); + [self throwErrorForQuery:query andArguments:args]; + } + } + else + { + DDLogError(@"Binding unsupported parameter in: %@", statement); + [self throwErrorForQuery:query andArguments:args]; + } + }]; + + return statement; +} + +-(id) getColumn:(int) column ofStatement:(sqlite3_stmt*) statement +{ + switch(sqlite3_column_type(statement, column)) + { + //SQLITE_INTEGER, SQLITE_FLOAT, SQLITE_TEXT, SQLITE_BLOB, or SQLITE_NULL + case(SQLITE_INTEGER): + { + NSNumber* returnInt = [NSNumber numberWithInt:sqlite3_column_int(statement, column)]; + return returnInt; + } + case(SQLITE_FLOAT): + { + NSNumber* returnFloat = [NSNumber numberWithDouble:sqlite3_column_double(statement, column)]; + return returnFloat; + } + case(SQLITE_TEXT): + { + NSString* returnString = [NSString stringWithUTF8String:(const char* _Nonnull) sqlite3_column_text(statement, column)]; + return returnString; + } + case(SQLITE_BLOB): + { + const char* bytes = (const char* _Nonnull) sqlite3_column_blob(statement, column); + int size = sqlite3_column_bytes(statement, column); + NSData* returnData = [NSData dataWithBytes:bytes length:size]; + return returnData; + } + case(SQLITE_NULL): + { + return nil; + } + } + return nil; +} + +-(void) throwErrorForQuery:(NSString*) query andArguments:(NSArray*) args +{ + int errcode = sqlite3_extended_errcode(self->_database); + NSString* error = [NSString stringWithUTF8String:sqlite3_errmsg(self->_database)]; + DDLogError(@"SQLite Exception: %d %@ for query '%@' having params %@", errcode, error, query ? query : @"", args ? args : @[]); + @synchronized(currentTransactions) { + DDLogError(@"currentTransactions: %@", currentTransactions); + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:[NSString stringWithFormat:@"%d: %@", errcode, error] userInfo:@{ + @"query": query ? query : [NSNull null], + @"args": args ? args : [NSNull null], + @"currentTransactions": currentTransactions, + }]; + } +} + +-(void) testThreadInstanceForQuery:(NSString*) query andArguments:(NSArray*) args +{ + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + if(!threadData[@"_sqliteInstancesForThread"] || !threadData[@"_sqliteInstancesForThread"][_dbFile] || self != threadData[@"_sqliteInstancesForThread"][_dbFile]) + { + DDLogError(@"Shared instance of MLSQLite used in wrong thread for query '%@' having params %@", query ? query : @"", args ? args : @[]); + @synchronized(currentTransactions) { + DDLogError(@"currentTransactions: %@", currentTransactions); + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"Shared instance of MLSQLite used in wrong thread!" userInfo:@{ + @"currentTransactions": currentTransactions, + @"query": query ? query : [NSNull null], + @"args": args ? args : [NSNull null] + }]; + } + } +} + +-(void) testTransactionsForQuery:(NSString*) query andArguments:(NSArray*) args +{ + //ignore pragma "queries" in this test --> pragma "queries" are allowed outside of transactions, too + if([[query uppercaseString] hasPrefix:@"PRAGMA "]) + return; + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + if([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] == 0) + { + DDLogError(@"Tried to run query outside of transaction: '%@' having params %@", query ? query : @"", args ? args : @[]); + @synchronized(currentTransactions) { + DDLogError(@"currentTransactions: %@", currentTransactions); + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"Tried to run query outside of transaction!" userInfo:@{ + @"currentTransactions": currentTransactions, + @"query": query ? query : [NSNull null], + @"args": args ? args : [NSNull null] + }]; + } + } +} + +-(void) checkQuery:(NSString*) query +{ + if(!query || [query length] == 0) + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"Empty sql query!" userInfo:nil]; +} + +-(BOOL) executeNonQuery:(NSString*) query andArguments:(NSArray *) args withException:(BOOL) throwException +{ + [self checkQuery:query]; + + //NOTE: we are not checking the thread instance here in this private api, but in the public api proxy methods + + BOOL toReturn; + sqlite3_stmt* statement = [self prepareQuery:query withArgs:args]; + if(statement != NULL) + { + int step; + while((step=sqlite3_step(statement)) == SQLITE_ROW) {} //clear data of all returned rows + sqlite3_finalize(statement); + if(step == SQLITE_DONE) + toReturn = YES; + else + { + DDLogVerbose(@"sqlite3_step(%@): %d (%d) [%s] --> %@", + query, + step, + sqlite3_extended_errcode(self->_database), + sqlite3_errmsg(self->_database), + [[NSThread currentThread] threadDictionary] + ); + if(throwException) + [self throwErrorForQuery:query andArguments:args]; + toReturn = NO; + } + } + else + { + DDLogError(@"nonquery returning NO with out OK %@", query); + if(throwException) + [self throwErrorForQuery:query andArguments:args]; + toReturn = NO; + } + return toReturn; +} + +-(id) internalExecuteScalar:(NSString*) query andArguments:(NSArray*) args +{ + id __block toReturn; + sqlite3_stmt* statement = [self prepareQuery:query withArgs:args]; + if(statement != NULL) + { + int step; + if((step=sqlite3_step(statement)) == SQLITE_ROW) + { + toReturn = [self getColumn:0 ofStatement:statement]; + while((step=sqlite3_step(statement)) == SQLITE_ROW) {} //clear data of all other rows + } + sqlite3_finalize(statement); + if(step != SQLITE_DONE) + [self throwErrorForQuery:query andArguments:args]; + } + else + { + //if noting else + [self throwErrorForQuery:query andArguments:args]; + } + return toReturn; +} + +#pragma mark - public API + +-(void) voidWriteTransaction:(monal_void_block_t) operations +{ + [self idWriteTransaction:^(void){ + operations(); + return (NSObject*)nil; //dummy return value + }]; +} + +-(BOOL) boolWriteTransaction:(monal_sqlite_bool_operations_t) operations +{ + return [[self idWriteTransaction:^(void){ + return [NSNumber numberWithBool:operations()]; + }] boolValue]; +} + +-(id) idWriteTransaction:(monal_sqlite_operations_t) operations +{ + [self beginWriteTransaction]; +#if !TARGET_OS_SIMULATOR + NSDate* startTime = [NSDate date]; +#endif + id retval = operations(); +#if !TARGET_OS_SIMULATOR + NSDate* endTime = [NSDate date]; + if([endTime timeIntervalSinceDate:startTime] > 2.0) + showErrorOnAlpha(nil, @"Write transaction blocking took %fs (longer than 2.0s): %@", (double)[endTime timeIntervalSinceDate:startTime], [NSThread callStackSymbols]); +#endif + [self endWriteTransaction]; + return retval; +} + +-(void) beginWriteTransaction +{ + [self testThreadInstanceForQuery:@"beginWriteTransaction" andArguments:nil]; + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + if([threadData[@"_sqliteStartedReadTransaction"][_dbFile] boolValue]) + @synchronized(currentTransactions) { + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"Tried to start write transaction inside running read transaction!" userInfo:@{ + @"currentTransactions": currentTransactions, + }]; + } + threadData[@"_sqliteTransactionsRunning"][_dbFile] = [NSNumber numberWithInt:([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] + 1)]; + if([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] > 1) + return; //begin only outermost transaction + BOOL retval; + do { + retval = [self executeNonQuery:@"BEGIN IMMEDIATE TRANSACTION;" andArguments:@[] withException:NO]; + if(!retval) + { + [NSThread sleepForTimeInterval:0.001f]; //wait one millisecond and retry again + @synchronized(currentTransactions) { + DDLogWarn(@"Retrying write transaction start: %@", @{ + @"newWriteTransactionVia": [NSThread callStackSymbols], + @"currentTransactions": currentTransactions, + }); + } + } + } while(!retval); + NSString* ownThread = [self calcThreadName]; + @synchronized(currentTransactions) { + currentTransactions[ownThread] = [NSThread callStackSymbols]; + } +} + +-(void) endWriteTransaction +{ + [self testThreadInstanceForQuery:@"endWriteTransaction" andArguments:nil]; + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + threadData[@"_sqliteTransactionsRunning"][_dbFile] = [NSNumber numberWithInt:[threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] - 1]; + if([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] == 0) + { + [self executeNonQuery:@"COMMIT;" andArguments:@[] withException:YES]; //commit only outermost transaction + NSString* ownThread = [self calcThreadName]; + @synchronized(currentTransactions) { + [currentTransactions removeObjectForKey:ownThread]; + } + } +} + +-(void) voidReadTransaction:(monal_void_block_t) operations +{ + [self idReadTransaction:^(void){ + operations(); + return (NSObject*)nil; //dummy return value + }]; +} + +-(BOOL) boolReadTransaction:(monal_sqlite_bool_operations_t) operations +{ + return [[self idReadTransaction:^(void){ + return [NSNumber numberWithBool:operations()]; + }] boolValue]; +} + +-(id) idReadTransaction:(monal_sqlite_operations_t) operations +{ + [self beginReadTransaction]; + id retval = operations(); + [self endReadTransaction]; + return retval; +} + +-(void) beginReadTransaction +{ + [self testThreadInstanceForQuery:@"beginReadTransaction" andArguments:nil]; + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + threadData[@"_sqliteTransactionsRunning"][_dbFile] = [NSNumber numberWithInt:([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] + 1)]; + if([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] > 1) + return; //begin only outermost transaction + BOOL retval; + do { + retval = [self executeNonQuery:@"BEGIN DEFERRED TRANSACTION;" andArguments:@[] withException:NO]; + if(!retval) + { + [NSThread sleepForTimeInterval:0.001f]; //wait one millisecond and retry again + @synchronized(currentTransactions) { + DDLogWarn(@"Retrying read transaction start: %@", @{ + @"newReadTransactionVia": [NSThread callStackSymbols], + @"currentTransactions": currentTransactions, + }); + } + } + } while(!retval); + threadData[@"_sqliteStartedReadTransaction"][_dbFile] = @YES; + NSString* ownThread = [self calcThreadName]; + @synchronized(currentTransactions) { + currentTransactions[ownThread] = [NSThread callStackSymbols]; + } +} + +-(void) endReadTransaction +{ + [self testThreadInstanceForQuery:@"endReadTransaction" andArguments:nil]; + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + threadData[@"_sqliteTransactionsRunning"][_dbFile] = [NSNumber numberWithInt:[threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] - 1]; + if([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] == 0) + { + [self executeNonQuery:@"COMMIT;" andArguments:@[] withException:YES]; //commit only outermost transaction + threadData[@"_sqliteStartedReadTransaction"][_dbFile] = @NO; + NSString* ownThread = [self calcThreadName]; + @synchronized(currentTransactions) { + [currentTransactions removeObjectForKey:ownThread]; + } + } +} + +-(id) executeScalar:(NSString*) query +{ + return [self executeScalar:query andArguments:@[]]; +} + +-(id) executeScalar:(NSString*) query andArguments:(NSArray*) args +{ + [self checkQuery:query]; + [self testThreadInstanceForQuery:query andArguments:args]; + [self testTransactionsForQuery:query andArguments:args]; + + return [self internalExecuteScalar:query andArguments:args]; +} + +-(NSArray*) executeScalarReader:(NSString*) query +{ + return [self executeScalarReader:query andArguments:@[]]; +} + +-(NSArray*) executeScalarReader:(NSString*) query andArguments:(NSArray*) args +{ + [self checkQuery:query]; + [self testThreadInstanceForQuery:query andArguments:args]; + [self testTransactionsForQuery:query andArguments:args]; + + NSMutableArray* __block toReturn = [NSMutableArray new]; + sqlite3_stmt* statement = [self prepareQuery:query withArgs:args]; + if(statement != NULL) + { + int step; + while((step=sqlite3_step(statement)) == SQLITE_ROW) + { + NSObject* returnData = [self getColumn:0 ofStatement:statement]; + //accessing an unset key in NSDictionary will return nil (nil can not be inserted directly into the dictionary) + if(returnData) + [toReturn addObject:returnData]; + } + sqlite3_finalize(statement); + if(step != SQLITE_DONE) + [self throwErrorForQuery:query andArguments:args]; + } + else + { + //if noting else + [self throwErrorForQuery:query andArguments:args]; + } + return toReturn; +} + +-(NSMutableArray*) executeReader:(NSString*) query +{ + return [self executeReader:query andArguments:@[]]; +} + +-(NSMutableArray*) executeReader:(NSString*) query andArguments:(NSArray*) args +{ + [self checkQuery:query]; + [self testThreadInstanceForQuery:query andArguments:args]; + [self testTransactionsForQuery:query andArguments:args]; + + NSMutableArray* toReturn = [NSMutableArray new]; + sqlite3_stmt* statement = [self prepareQuery:query withArgs:args]; + if(statement != NULL) + { + int step; + while((step=sqlite3_step(statement)) == SQLITE_ROW) + { + NSMutableDictionary* row = [NSMutableDictionary new]; + int counter = 0; + while(counter < sqlite3_column_count(statement)) + { + NSString* columnName = [NSString stringWithUTF8String:sqlite3_column_name(statement, counter)]; + NSObject* returnData = [self getColumn:counter ofStatement:statement]; + //accessing an unset key in NSDictionary will return nil (nil can not be inserted directly into the dictionary) + if(returnData) + [row setObject:returnData forKey:columnName]; + counter++; + } + [toReturn addObject:row]; + } + sqlite3_finalize(statement); + if(step != SQLITE_DONE) + [self throwErrorForQuery:query andArguments:args]; + } + else + { + //if noting else + DDLogVerbose(@"reader nil with sql not ok: %@", query); + [self throwErrorForQuery:query andArguments:args]; + } + return toReturn; +} + +-(BOOL) executeNonQuery:(NSString*) query +{ + [self testThreadInstanceForQuery:query andArguments:@[]]; + [self testTransactionsForQuery:query andArguments:@[]]; + return [self executeNonQuery:query andArguments:@[] withException:YES]; +} + +-(BOOL) executeNonQuery:(NSString*) query andArguments:(NSArray*) args +{ + [self testThreadInstanceForQuery:query andArguments:args]; + [self testTransactionsForQuery:query andArguments:args]; + return [self executeNonQuery:query andArguments:args withException:YES]; +} + +-(NSNumber*) lastInsertId +{ + [self testThreadInstanceForQuery:@"lastInsertId" andArguments:nil]; + [self testTransactionsForQuery:@"lastInsertId" andArguments:nil]; + return [NSNumber numberWithInt:(int)sqlite3_last_insert_rowid(self->_database)]; +} + +-(void) enableWAL +{ + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + MLAssert([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] == 0, @"Could not enable wal, inside transaction!", (@{ + @"threadDictionary": threadData + })); + NSString* mode = [self internalExecuteScalar:@"PRAGMA journal_mode;" andArguments:@[]]; + if([mode isEqualToString:@"wal"]) + return; + mode = [self internalExecuteScalar:@"PRAGMA journal_mode=WAL;" andArguments:@[]]; + if([mode isEqualToString:@"wal"]) + DDLogWarn(@"Transaction mode set to WAL"); + else + @throw [NSException exceptionWithName:@"SQLite3Exception" reason:@"Failed to enable sqlite WAL mode" userInfo:@{ + @"file": _dbFile, + @"mode": mode + }]; +} + +-(void) checkpointWal +{ + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + //being inside a transaction is non-fatal, the db file will just not be up to date then + if([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] == 0) + { + NSArray* result = [self executeReader:@"PRAGMA wal_checkpoint(TRUNCATE);"]; + DDLogInfo(@"Chekpointing returned: %@", result); + } + else + DDLogError(@"Could not checkpoint wal, inside transaction: %@", threadData); +} + +// optimize db +-(void) vacuum +{ + //trying to vaccum the db inside a transaction is non-fatal, the db file will just not be shrinked then + DDLogDebug(@"Vacuum DB"); + NSMutableDictionary* threadData = [[NSThread currentThread] threadDictionary]; + if([threadData[@"_sqliteTransactionsRunning"][_dbFile] intValue] == 0) + { + [self executeNonQuery:@"VACUUM;" andArguments:@[] withException:YES]; + DDLogDebug(@"Vacuum DB success"); + } + else + DDLogError(@"Could not vaccum db, inside transaction: %@", threadData); +} + +@end diff --git a/Monal/Classes/MLSearchViewController.h b/Monal/Classes/MLSearchViewController.h new file mode 100644 index 0000000..d83a20c --- /dev/null +++ b/Monal/Classes/MLSearchViewController.h @@ -0,0 +1,41 @@ +// +// MLSearchViewController.h +// Monal +// +// Created by jimtsai (poormusic2001@gmail.com) on 2020/9/23. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLContact.h" +#import "DataLayer.h" + +@protocol SearchResultDelegate +- (void) doGoSearchResultAction:(NSNumber*_Nullable) nextDBId; +- (void) doReloadActionForAllTableView; +- (void) doReloadHistoryForSearch; +- (void) doGetMsgData; +- (void) doShowLoadingHistory:(NSString* _Nonnull) title; +@end + +NS_ASSUME_NONNULL_BEGIN + +@interface MLSearchViewController : UISearchController +@property (nonatomic, strong) MLContact *contact; +@property (nonatomic, weak) NSString *jid; +@property (nonatomic, weak) id searchResultDelegate; +@property (nonatomic) BOOL isLoadingHistory; +@property (nonatomic) BOOL isGoingUp; + + +- (void) getSearchData:(NSString*) queryText; +- (NSMutableAttributedString*) doSearchKeyword:(NSString*) keyword onText:(NSString*) allText andInbound:(BOOL) inDirection; +- (BOOL) isDBIdExistent:(NSNumber*) dbId; +- (void) setResultToolBar; +- (void) setMessageIndexPath:(NSNumber*)idxPath withDBId:(NSNumber*)dbId; +- (NSNumber*) getMessageIndexPathForDBId:(NSNumber*)dbId; +- (void) doNextAction; +- (void) doPreviousAction; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLSearchViewController.m b/Monal/Classes/MLSearchViewController.m new file mode 100644 index 0000000..737d903 --- /dev/null +++ b/Monal/Classes/MLSearchViewController.m @@ -0,0 +1,308 @@ +// +// MLSearchViewController.m +// Monal +// +// Created by jimtsai (poormusic2001@gmail.com) on 2020/9/23. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLSearchViewController.h" +#import "DataLayer.h" + +@interface MLSearchViewController () +@property (nonatomic, strong) NSMutableArray* searchResultMessageList; +@property (nonatomic, strong) NSMutableDictionary* searchResultMessageDictionary; +@property (nonatomic, strong) NSMutableDictionary* messageDictionary; +@property (nonatomic, strong) UIToolbar* toolbar; +@property (nonatomic, strong) UIBarButtonItem* searchResultIndicatorItem; +@property (nonatomic, strong) UIBarButtonItem* prevItem; +@property (nonatomic, strong) UIBarButtonItem* nextItem; +@property (nonatomic, strong) UIBarButtonItem* epmtyItem; + +@property (nonatomic) unsigned int curIdxHistory; +@end + +@implementation MLSearchViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + + // Do any additional setup after loading the view. + self.searchBar.delegate = self; + self.isLoadingHistory = NO; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + CGFloat xAxis = self.searchBar.frame.origin.x; + CGFloat yAxis = self.searchBar.frame.origin.y; + CGFloat height = self.searchBar.frame.size.height; + CGFloat width = self.searchBar.frame.size.width; + if (yAxis > 50) { + self.searchBar.frame = CGRectMake(xAxis, yAxis-50, width, height); + } + + self.toolbar = [[UIToolbar alloc] initWithFrame:CGRectMake(0, 0, self.searchBar.frame.size.width, self.searchBar.frame.size.height)]; + UniChar upCode = 0x2191; + UniChar downCode = 0x2193; + NSString *upCodeString = [NSString stringWithCharacters:&upCode length:1]; + NSString *downCodeString = [NSString stringWithCharacters:&downCode length:1]; + self.prevItem = [[UIBarButtonItem alloc] initWithTitle:upCodeString style:UIBarButtonItemStylePlain target:self action:@selector(doPreviousAction)]; + self.nextItem = [[UIBarButtonItem alloc] initWithTitle:downCodeString style:UIBarButtonItemStylePlain target:self action:@selector(doNextAction)]; + self.searchResultIndicatorItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:self action:nil]; + self.epmtyItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; + [self.toolbar sizeToFit]; + + self.curIdxHistory = 0; + + if (!self.searchResultMessageDictionary) + self.searchResultMessageDictionary = [NSMutableDictionary new]; + + if (!self.messageDictionary) + self.messageDictionary = [NSMutableDictionary new]; + + [self.searchResultDelegate doGetMsgData]; +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + + self.isLoadingHistory = NO; + self.searchResultMessageList = nil; + self.searchResultMessageDictionary = nil; + self.messageDictionary = nil; + self.toolbar = nil; + self.searchResultIndicatorItem = nil; + self.prevItem = nil; + self.nextItem = nil; + self.epmtyItem = nil; + self.curIdxHistory = 0; +} + +- (instancetype)initWithSearchResultsController:(UIViewController *)searchResultsController +{ + return [super initWithSearchResultsController:searchResultsController]; +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + [self defaultStatus]; + [self.searchResultDelegate doReloadActionForAllTableView]; +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + [self.searchBar becomeFirstResponder]; +} + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + if ([searchText length] == 0) + { + [self defaultStatus]; + [self.searchResultDelegate doReloadActionForAllTableView]; + } + else + { + [self getSearchData:searchText]; + + if ([self.searchResultMessageList count] >0) + { + self.toolbar.items = @[self.epmtyItem, self.prevItem, self.nextItem, self.searchResultIndicatorItem]; + #if TARGET_OS_MACCATALYST + CGFloat yAxis = self.view.frame.size.height - self.searchBar.frame.size.height; + [self.toolbar setFrame:CGRectMake(0, yAxis, self.searchBar.frame.size.width, self.searchBar.frame.size.height)]; + [self.view addSubview:self.toolbar]; + #else + self.searchBar.inputAccessoryView = self.toolbar; + #endif + self.curIdxHistory = (int)[self.searchResultMessageList count] - 1; + + [self setResultIndicatorTitle:@"" onlyHint:NO]; + [self.searchBar reloadInputViews]; + } + else + { + self.curIdxHistory = 0; + self.toolbar.items = @[self.epmtyItem, self.searchResultIndicatorItem]; + [self setResultIndicatorTitle:NSLocalizedString(@"No search result.", @"") onlyHint:YES]; + } + [self updateMsgDictionary]; + } +} + +-(void) doNextAction +{ + self.isGoingUp = NO; + if (!self.isLoadingHistory) + { + self.curIdxHistory += 1; + if (self.curIdxHistory > self.searchResultMessageList.count - 1) + self.curIdxHistory = (int) self.searchResultMessageList.count - 1; + + if([self getMessageIndexPathForDBId:((MLMessage*)self.searchResultMessageList[self.curIdxHistory]).messageDBId] != nil) + { + [self setResultIndicatorTitle:@"" onlyHint:NO]; + [self.searchResultDelegate doGoSearchResultAction:((MLMessage*)self.searchResultMessageList[self.curIdxHistory]).messageDBId]; + } + else + { + //Load old message + self.isLoadingHistory = YES; + self.curIdxHistory -= 1; + [self.searchResultDelegate doReloadHistoryForSearch]; + [self setResultIndicatorTitle:NSLocalizedString(@"Loading more Messages from Server", @"") onlyHint:YES]; + } + } + else + [self setResultIndicatorTitle:NSLocalizedString(@"Loading more Messages from Server", @"") onlyHint:YES]; +} + +-(void) doPreviousAction +{ + self.isGoingUp = YES; + if(!self.isLoadingHistory) + { + self.curIdxHistory -= 1; + if (self.curIdxHistory <= 0) + self.curIdxHistory = 0; + + if([self getMessageIndexPathForDBId:((MLMessage*)self.searchResultMessageList[self.curIdxHistory]).messageDBId] != nil) + { + [self setResultIndicatorTitle:@"" onlyHint:NO]; + [self.searchResultDelegate doGoSearchResultAction:((MLMessage*)self.searchResultMessageList[self.curIdxHistory]).messageDBId]; + } + else + { + //Load old message + self.curIdxHistory += 1; + self.isLoadingHistory = YES; + [self.searchResultDelegate doReloadHistoryForSearch]; + [self setResultIndicatorTitle:NSLocalizedString(@"Loading more Messages from Server", @"") onlyHint:YES]; + } + } + else + [self setResultIndicatorTitle:NSLocalizedString(@"Loading more Messages from Server", @"") onlyHint:YES]; +} + +- (void)setResultIndicatorTitle:(NSString*)title onlyHint:(BOOL)isOnlyHint +{ + NSString* finalTitle = @""; + + if (!isOnlyHint) + { + finalTitle = [NSString stringWithFormat:@"%d/%d",(self.curIdxHistory+1), (int)self.searchResultMessageList.count]; + } + else + { + finalTitle = title; + [self.searchResultDelegate doShowLoadingHistory:finalTitle]; + } + + [self.searchResultIndicatorItem setTitle:finalTitle]; + [self.searchResultDelegate doReloadActionForAllTableView]; +} + +- (void)updateMsgDictionary +{ + [self.searchResultMessageDictionary removeAllObjects]; + for (MLMessage *msg in self.searchResultMessageList) + { + [self.searchResultMessageDictionary setObject:msg forKey:msg.messageDBId]; + } +} + +- (BOOL)isDBIdExistent:(NSNumber*) dbId +{ + if ([self.searchResultMessageDictionary objectForKey:dbId]) + { + return YES; + } + + return NO; +} + +- (void)defaultStatus +{ + self.toolbar.items = @[]; + if (self.searchResultMessageList != nil) + [self.searchResultMessageList removeAllObjects]; + + if (self.searchResultMessageDictionary != nil) + [self.searchResultMessageDictionary removeAllObjects]; + + self.searchBar.inputAccessoryView = nil; + [self.searchBar reloadInputViews]; +} + +- (void)setResultToolBar +{ + [self setResultIndicatorTitle:@"" onlyHint:NO]; +} + +- (void) getSearchData:(NSString*) queryText +{ + NSArray* searchResultArray = [[DataLayer sharedInstance] searchResultOfHistoryMessageWithKeyWords:queryText betweenContact:self.contact]; + [self.searchResultMessageList removeAllObjects]; + self.searchResultMessageList = [searchResultArray mutableCopy]; +} + +-(NSMutableAttributedString*) doSearchKeyword:(NSString*) keyword onText:(NSString*) allText andInbound:(BOOL) inDirection +{ + NSMutableAttributedString* attributedString = [[NSMutableAttributedString alloc] initWithString:allText]; + NSRange allTextRange = NSMakeRange(0, allText.length); + + NSRange foundRange; + while (allTextRange.location < allText.length) { + foundRange = [allText rangeOfString:keyword options:NSCaseInsensitiveSearch range:allTextRange]; + if (foundRange.location != NSNotFound) + { + allTextRange.location = foundRange.location + foundRange.length; + allTextRange.length = allText.length - allTextRange.location; + if (inDirection) + { + [attributedString addAttribute:NSBackgroundColorAttributeName value:[UIColor yellowColor] range:foundRange]; + } + else + { + [attributedString addAttribute:NSBackgroundColorAttributeName value:[UIColor grayColor] range:foundRange]; + } + } + else + { + break; + } + } + + return attributedString; +} + +-(void)setMessageIndexPath:(NSNumber*)idxPath withDBId:(NSNumber*)dbId +{ + [self.messageDictionary setObject:idxPath forKey:dbId]; +} + +-(NSNumber*) getMessageIndexPathForDBId:(NSNumber*) dbId +{ + return [self.messageDictionary objectForKey:dbId]; +} + +-(void)escapeSearchPressed:(UIKeyCommand*)keyCommand +{ + [self defaultStatus]; + [self.searchResultDelegate doReloadActionForAllTableView]; + [self setActive:NO]; + [self dismissViewControllerAnimated:NO completion:nil]; +} + +// List of custom hardware key commands +- (NSArray *)keyCommands { + return @[ + // esc + [UIKeyCommand keyCommandWithInput:UIKeyInputEscape modifierFlags:0 action:@selector(escapeSearchPressed:)] + ]; +} +@end diff --git a/Monal/Classes/MLSettingCell.h b/Monal/Classes/MLSettingCell.h new file mode 100644 index 0000000..6063ed9 --- /dev/null +++ b/Monal/Classes/MLSettingCell.h @@ -0,0 +1,39 @@ +// +// MLSettingCell.h +// Monal +// +// Created by Anurodh Pokharel on 7/23/13. +// +// + +#import + +@protocol MLSettingCellDelegate + +-(void)updateUI; + +@end + +@interface MLSettingCell : UITableViewCell + +@property (nonatomic, assign) BOOL switchEnabled; +@property (nonatomic, assign) BOOL textEnabled; +@property (nonatomic, weak) UIViewController* parent; +@property (nonatomic, weak) id SettingCellDelegate; + +/** + NSuserdefault key to use + */ +@property (nonatomic, strong) NSString* defaultKey; + +/** + UIswitch + */ +@property (nonatomic, strong) UISwitch* toggleSwitch; + +/** + Textinput field + */ +@property (nonatomic, strong) UITextField* textInputField; + +@end diff --git a/Monal/Classes/MLSettingCell.m b/Monal/Classes/MLSettingCell.m new file mode 100644 index 0000000..04184f5 --- /dev/null +++ b/Monal/Classes/MLSettingCell.m @@ -0,0 +1,80 @@ +// +// MLSettingCell.m +// Monal +// +// Created by Anurodh Pokharel on 7/23/13. +// +// + +#import "MLSettingCell.h" +#import "MLXMPPManager.h" +#import "xmpp.h" + +@implementation MLSettingCell + +-(id) initWithStyle:(UITableViewCellStyle) style reuseIdentifier:(NSString*) reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if(self) + { + // Initialization code + self.selectionStyle = UITableViewCellSelectionStyleNone; + + _textInputField = [[UITextField alloc] initWithFrame:CGRectZero]; + _toggleSwitch = [[UISwitch alloc] initWithFrame:CGRectZero]; + } + return self; +} + +-(void) layoutSubviews +{ + [super layoutSubviews]; //The default implementation of the layoutSubviews + CGRect textLabelFrame = self.textLabel.frame; + + //this is to account for padding in the grouped tableview cell + int padding = 30; + if([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPad) + padding = 80; + + if(self.switchEnabled) + { + _toggleSwitch.on = [[HelperTools defaultsDB] boolForKey:_defaultKey]; + [ _toggleSwitch addTarget:self action:@selector(switchChange) forControlEvents:UIControlEventValueChanged]; + CGRect frame = CGRectMake(self.frame.size.width - 50 - padding, textLabelFrame.origin.y + 9, 0, 0); + _toggleSwitch.frame = frame; + [self.contentView addSubview:_toggleSwitch]; + } + + if(self.textEnabled) + { + CGRect frame=CGRectMake(self.frame.size.width-79-padding, + textLabelFrame.origin.y+9,79, + textLabelFrame.size.height*2/3); + _textInputField.frame=frame; + _textInputField.returnKeyType=UIReturnKeyDone; + _textInputField.delegate=self; + _textInputField.text= [[HelperTools defaultsDB] stringForKey: _defaultKey]; + [self.contentView addSubview: _textInputField ]; + } +} + +-(void) switchChange +{ + [[HelperTools defaultsDB] setBool:_toggleSwitch.on forKey:_defaultKey]; + [self.SettingCellDelegate updateUI]; +} + +#pragma mark uitextfield delegate +-(void) textFieldDidBeginEditing:(UITextField*) textField +{ + +} + +-(BOOL) textFieldShouldReturn:(UITextField*) textField +{ + [[HelperTools defaultsDB] setObject:_textInputField.text forKey: _defaultKey]; + [textField resignFirstResponder]; + return YES; +} + +@end diff --git a/Monal/Classes/MLSettingsAboutViewController.h b/Monal/Classes/MLSettingsAboutViewController.h new file mode 100644 index 0000000..2030585 --- /dev/null +++ b/Monal/Classes/MLSettingsAboutViewController.h @@ -0,0 +1,17 @@ +// +// MLSettingsAboutViewController.h +// Monal +// +// Created by jimtsai (poormusic2001@gmail.com) on 2021/4/12. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MLSettingsAboutViewController : UIViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLSettingsAboutViewController.m b/Monal/Classes/MLSettingsAboutViewController.m new file mode 100644 index 0000000..066b94c --- /dev/null +++ b/Monal/Classes/MLSettingsAboutViewController.m @@ -0,0 +1,47 @@ +// +// MLSettingsAboutViewController.m +// Monal +// +// Created by jimtsai (poormusic2001@gmail.com) on 2021/4/12. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import "MLSettingsAboutViewController.h" +#import "HelperTools.h" + +@interface MLSettingsAboutViewController () +@property (weak, nonatomic) IBOutlet UITextView* aboutVersion; + +@end + +@implementation MLSettingsAboutViewController + +- (void) viewDidLoad +{ + [super viewDidLoad]; + + [self.aboutVersion setText: [HelperTools appBuildVersionInfoFor:MLVersionTypeIQ]]; + + UIBarButtonItem* leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemClose target:self action:@selector(close:)]; + self.navigationItem.leftBarButtonItem = leftBarButtonItem; +} + +- (NSArray*) keyCommands +{ + return @[ + [UIKeyCommand keyCommandWithInput:UIKeyInputEscape modifierFlags:0 action:@selector(close:)] + ]; +} + +-(void) close:(id)sender +{ + [self dismissViewControllerAnimated:YES completion:nil]; +} + +-(IBAction) copyTxt:(id)sender +{ + UIPasteboard* pasteBoard = UIPasteboard.generalPasteboard; + pasteBoard.string = self.aboutVersion.text; +} + +@end diff --git a/Monal/Classes/MLSettingsTableViewController.h b/Monal/Classes/MLSettingsTableViewController.h new file mode 100644 index 0000000..719e509 --- /dev/null +++ b/Monal/Classes/MLSettingsTableViewController.h @@ -0,0 +1,20 @@ +// +// MLSettingsTableViewController.h +// Monal +// +// Created by Anurodh Pokharel on 12/26/17. +// Copyright © 2017 Monal.im. All rights reserved. +// + +#import +@import MessageUI; +@import StoreKit; +#import "MLConstants.h" +#import "AccountListController.h" + +@interface MLSettingsTableViewController : AccountListController + +- (IBAction)close:(id) sender; +-(void) presentSplitPlaceholder; + +@end diff --git a/Monal/Classes/MLSettingsTableViewController.m b/Monal/Classes/MLSettingsTableViewController.m new file mode 100644 index 0000000..326b120 --- /dev/null +++ b/Monal/Classes/MLSettingsTableViewController.m @@ -0,0 +1,537 @@ +// +// MLSettingsTableViewController.m +// Monal +// +// Created by Anurodh Pokharel on 12/26/17. +// Copyright © 2017 Monal.im. All rights reserved. +// + +#import "MLSettingsTableViewController.h" +#import "MLWebViewController.h" +#import "MLSwitchCell.h" +#import "HelperTools.h" +#import "DataLayer.h" +#import "MLXMPPManager.h" +#import "XMPPEdit.h" +#import "MonalAppDelegate.h" +#import "ActiveChatsViewController.h" +#import + +@import SafariServices; + +enum kSettingSection { + kSettingSectionAccounts, + kSettingSectionApp, + kSettingSectionSupport, + kSettingSectionAbout, + kSettingSectionCount +}; + +enum SettingsAccountRows { +#ifndef IS_QUICKSY + QuickSettingsRow, + AdvancedSettingsRow, +#endif + SettingsAccountRowsCnt +}; + +enum SettingsAppRows { + GeneralSettingsRow, + SoundsRow, + SettingsAppRowsCnt +}; + +enum SettingsSupportRow { + EmailRow, + SubmitABugRow, + OpenFAQRow, + SettingsSupportRowCnt +}; + +enum SettingsAboutRows { + RateMonalRow, + OpenSourceRow, + PrivacyRow, + AboutRow, +#ifdef DEBUG + LogRow, +#endif + VersionRow, + SettingsAboutRowsCntORLogRow, + SettingsAboutRowsWithLogCnt +}; + +//this will hold all disabled rows of all enums (this is needed because the code below still references these rows) +enum DummySettingsRows { +#ifdef IS_QUICKSY + QuickSettingsRow, + AdvancedSettingsRow, +#endif + DummySettingsRowsBegin = 100, +}; + +@interface MLSettingsTableViewController () { + int _tappedVersionInfo; +} + +@property (nonatomic, strong) NSArray* sections; +@property (nonatomic, strong) NSArray* accountRows; +@property (nonatomic, strong) NSArray* appRows; +@property (nonatomic, strong) NSArray* supportRows; +@property (nonatomic, strong) NSDateFormatter* uptimeFormatter; + +@property (nonatomic, strong) NSIndexPath* selected; + +@end + +@implementation MLSettingsTableViewController + + +-(IBAction) close:(id) sender +{ + _tappedVersionInfo = 0; + [self dismissViewControllerAnimated:YES completion:nil]; +} + +-(void) viewDidLoad +{ + [super viewDidLoad]; + [self setupAccountsView]; + + [self.tableView registerNib:[UINib nibWithNibName:@"MLSwitchCell" bundle:[NSBundle mainBundle]] forCellReuseIdentifier:@"AccountCell"]; + + self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; +#if !TARGET_OS_MACCATALYST + self.splitViewController.primaryBackgroundStyle = UISplitViewControllerBackgroundStyleSidebar; +#endif +} + +-(void) viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + [self refreshAccountList]; + + _tappedVersionInfo = 0; + self.selected = nil; + + [self presentSplitPlaceholder]; +} + +-(void) viewWillDisappear:(BOOL) animated +{ + [super viewWillDisappear:animated]; + [((MonalAppDelegate*)UIApplication.sharedApplication.delegate).activeChats sheetDismissed]; +} + +-(void) presentSplitPlaceholder +{ + // only show placeholder if we use a split view + if(!self.splitViewController.collapsed) + { + DDLogVerbose(@"Presenting Settings Placeholder..."); + UIViewController* detailsViewController = [[SwiftuiInterface new] makeViewWithName:@"ChatPlaceholder"]; + [self showDetailViewController:detailsViewController sender:self]; + } +} + +#pragma mark - key commands + +-(BOOL) canBecomeFirstResponder +{ + return YES; +} + +-(NSArray*) keyCommands +{ + return @[[UIKeyCommand keyCommandWithInput:UIKeyInputEscape modifierFlags:0 action:@selector(close:)]]; +} + +#pragma mark - Table view data source + +-(NSInteger) numberOfSectionsInTableView:(UITableView*) tableView +{ + return kSettingSectionCount; +} + +-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + switch(section) + { +#ifdef IS_QUICKSY + case kSettingSectionAccounts: return [self getAccountNum] + SettingsAccountRowsCnt; +#else + case kSettingSectionAccounts: return [self getAccountNum] + SettingsAccountRowsCnt; +#endif + case kSettingSectionApp: return SettingsAppRowsCnt; + case kSettingSectionSupport: return SettingsSupportRowCnt; +#ifndef DEBUG + case kSettingSectionAbout: return [[HelperTools defaultsDB] boolForKey:@"showLogInSettings"] ? SettingsAboutRowsWithLogCnt : SettingsAboutRowsCntORLogRow; +#else + case kSettingSectionAbout: return SettingsAboutRowsCntORLogRow; +#endif + default: + unreachable(); + } + return 0; +} + +-(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender +{ + if([segue.identifier isEqualToString:@"showOpenSource"]) + { + UINavigationController* nav = (UINavigationController*) segue.destinationViewController; + MLWebViewController* web = (MLWebViewController*) nav.topViewController; + + NSBundle* mainBundle = [NSBundle mainBundle]; + NSString* myFile = [mainBundle pathForResource: @"opensource" ofType: @"html"]; + + [web initViewWithUrl:[NSURL fileURLWithPath:myFile]]; + } + else if([segue.identifier isEqualToString:@"showAbout"]) + { + UINavigationController* nav = (UINavigationController*) segue.destinationViewController; + MLWebViewController* web = (MLWebViewController*) nav.topViewController; + + [web initViewWithUrl:[NSURL URLWithString:@"https://monal-im.org/about"]]; + } + else if([segue.identifier isEqualToString:@"showPrivacy"]) + { + UINavigationController* nav = (UINavigationController*) segue.destinationViewController; + MLWebViewController* web = (MLWebViewController*) nav.topViewController; + + [web initViewWithUrl:[NSURL URLWithString:@"https://monal-im.org/privacy"]]; + } + else if([segue.identifier isEqualToString:@"showBug"]) + { + UINavigationController* nav = (UINavigationController*) segue.destinationViewController; + MLWebViewController* web = (MLWebViewController*) nav.topViewController; + + [web initViewWithUrl:[NSURL URLWithString:@"https://github.com/monal-im/Monal/issues"]]; + } + else if([segue.identifier isEqualToString:@"showFAQ"]) + { + UINavigationController* nav = (UINavigationController*) segue.destinationViewController; + MLWebViewController* web = (MLWebViewController*) nav.topViewController; + + [web initViewWithUrl:[NSURL URLWithString:@"https://github.com/monal-im/Monal/wiki/FAQ---Frequently-Asked-Questions"]]; + } + else if([segue.identifier isEqualToString:@"editXMPP"]) + { + XMPPEdit* editor = (XMPPEdit*) segue.destinationViewController.childViewControllers.firstObject; // segue.destinationViewController; + + if(self.selected && self.selected.row >= (int) [self getAccountNum]) + { + editor.accountID = [NSNumber numberWithInt:-1]; + } + else + { + MLAssert(self.selected != nil, @"self.selected must not be nil"); + editor.originIndex = self.selected; + editor.accountID = [self getAccountIDByIndex:self.selected.row]; + } + } +} + +-(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath +{ + MLSwitchCell* cell = [tableView dequeueReusableCellWithIdentifier:@"AccountCell" forIndexPath:indexPath]; + switch((int)indexPath.section) + { + case kSettingSectionAccounts: { + if(indexPath.row < (int) [self getAccountNum]) + { + // User selected an account + [self initContactCell:cell forAccNo:indexPath.row]; + } + else + { +#ifndef IS_QUICKSY + MLAssert(indexPath.row - [self getAccountNum] < SettingsAccountRowsCnt, @"Tried to tap onto a row meant to be for a concrete account, not for quick or advanced settings"); + + // User selected one of the 'add account' promts + switch(indexPath.row - [self getAccountNum]) { + case QuickSettingsRow: + [cell initTapCell:NSLocalizedString(@"Add Account", @"")]; + break; + case AdvancedSettingsRow: + [cell initTapCell:NSLocalizedString(@"Add Account (advanced)", @"")]; + break; + default: + unreachable(); + } +#endif + } + break; + } + case kSettingSectionApp: { + switch(indexPath.row) { + case GeneralSettingsRow: + [cell initTapCell:NSLocalizedString(@"General Settings", @"")]; + break; + case SoundsRow: + [cell initTapCell:NSLocalizedString(@"Sounds", @"")]; + break; + default: + unreachable(); + } + break; + } + case kSettingSectionSupport: { + switch(indexPath.row) { + case EmailRow: + [cell initTapCell:NSLocalizedString(@"Email Support", @"")]; + break; + case SubmitABugRow: + [cell initTapCell:NSLocalizedString(@"Submit A Bug", @"")]; + break; + case OpenFAQRow: + [cell initTapCell:NSLocalizedString(@"Frequently Asked Questions", @"")]; + break; + default: + unreachable(); + } + break; + } + case kSettingSectionAbout: { + switch(indexPath.row) { + case RateMonalRow: { +#ifdef IS_QUICKSY + [cell initTapCell:NSLocalizedString(@"Rate Quicksy", @"")]; +#else + [cell initTapCell:NSLocalizedString(@"Rate Monal", @"")]; +#endif + break; + } + case OpenSourceRow: { + [cell initTapCell:NSLocalizedString(@"Open Source", @"")]; + break; + } + case PrivacyRow: { + [cell initTapCell:NSLocalizedString(@"Privacy", @"")]; + break; + } + case AboutRow: { + [cell initTapCell:NSLocalizedString(@"About", @"")]; + break; + } + case VersionRow: { + [cell initCell:NSLocalizedString(@"Version", @"") withLabel:[HelperTools appBuildVersionInfoFor:MLVersionTypeIQ]]; + break; + } +#ifdef DEBUG + case LogRow: +#endif + case SettingsAboutRowsCntORLogRow: { + [cell initTapCell:NSLocalizedString(@"Debug", @"")]; + break; + } + default: { + unreachable(); + } + } + break; + } + default: + unreachable(); + } + return cell; +} + +-(NSString*) tableView:(UITableView*) tableView titleForHeaderInSection:(NSInteger) section +{ + switch(section) { + case kSettingSectionAccounts: + return nil; //the account section does not need a heading (its the first one) + case kSettingSectionApp: + return NSLocalizedString(@"App", @""); + case kSettingSectionSupport: + return NSLocalizedString(@"Support", @""); + case kSettingSectionAbout: + return NSLocalizedString(@"About", @""); + default: + unreachable(); + } + return nil; //needed to make the compiler happy +} + +-(void)tableView:(UITableView*) tableView didSelectRowAtIndexPath:(NSIndexPath*) indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + switch(indexPath.section) + { + case kSettingSectionAccounts: { + self.selected = indexPath; + if(indexPath.row < (int) [self getAccountNum]) + [self performSegueWithIdentifier:@"editXMPP" sender:self]; + else + { + switch(indexPath.row - [self getAccountNum]) { + case QuickSettingsRow: { + UIViewController* loginView = [[SwiftuiInterface new] makeViewWithName:@"LogIn"]; + [self showDetailViewController:loginView sender:self]; + break; + } + case AdvancedSettingsRow: { + UIViewController* advancedLoginView = [[SwiftuiInterface new] makeViewWithName:@"AdvancedLogIn"]; + [self showDetailViewController:advancedLoginView sender:self]; + break; + } + default: + unreachable(); + } + } + break; + } + case kSettingSectionApp: { + switch(indexPath.row) { + + case GeneralSettingsRow: { + UIViewController* privacyViewController = [[SwiftuiInterface new] makeViewWithName:@"GeneralSettings"]; + [self showDetailViewController:privacyViewController sender:self]; + break; + } + case SoundsRow: + [self performSegueWithIdentifier:@"showSounds" sender:self]; + break; + default: + unreachable(); + } + break; + } + case kSettingSectionSupport: { + switch(indexPath.row) { + case EmailRow: + [self composeMail]; + break; + case SubmitABugRow: + [self performSegueWithIdentifier:@"showBug" sender:self]; + break; + case OpenFAQRow: + [self performSegueWithIdentifier:@"showFAQ" sender:self]; + break; + default: + unreachable(); + } + break; + } + case kSettingSectionAbout: { + switch(indexPath.row) { + case RateMonalRow: +#if TARGET_OS_MACCATALYST + [self openStoreProductViewControllerWithITunesItemIdentifier:1637078500]; +#elif defined(IS_QUICKSY) + [self openStoreProductViewControllerWithITunesItemIdentifier:6538727270]; +#else + [self openStoreProductViewControllerWithITunesItemIdentifier:317711500]; +#endif + break; + case OpenSourceRow: + [self performSegueWithIdentifier:@"showOpenSource" sender:self]; + break; + case PrivacyRow: + [self performSegueWithIdentifier:@"showPrivacy" sender:self]; + break; + case AboutRow: + [self performSegueWithIdentifier:@"showAbout" sender:self]; + break; +#ifdef DEBUG + case LogRow: +#endif + case SettingsAboutRowsCntORLogRow:{ + UIViewController* logView = [[SwiftuiInterface new] makeViewWithName:@"DebugView"]; + [self showDetailViewController:logView sender:self]; + break; + } + case VersionRow: { +#ifndef DEBUG + if(_tappedVersionInfo >= 16) + { + [[HelperTools defaultsDB] setBool:YES forKey:@"showLogInSettings"]; + [tableView reloadData]; + } + else + _tappedVersionInfo++; +#endif + UIPasteboard* pastboard = UIPasteboard.generalPasteboard; + pastboard.string = [HelperTools appBuildVersionInfoFor:MLVersionTypeIQ]; + break; + } + default: + unreachable(); + } + break; + } + default: + unreachable(); + } +} + +-(void) openLink:(NSString *) link +{ + NSURL* url = [NSURL URLWithString:link]; + + if ([url.scheme isEqualToString:@"http"] || [url.scheme isEqualToString:@"https"]) { + SFSafariViewController* safariView = [[ SFSafariViewController alloc] initWithURL:url]; + [self presentViewController:safariView animated:YES completion:nil]; + } +} + +#pragma mark - Actions + + +-(void) openStoreProductViewControllerWithITunesItemIdentifier:(NSInteger) iTunesItemIdentifier { + SKStoreProductViewController *storeViewController = [SKStoreProductViewController new]; + + storeViewController.delegate = self; + + NSNumber* identifier = [NSNumber numberWithInteger:iTunesItemIdentifier]; + //, @"action":@"write-review" + NSDictionary* parameters = @{ SKStoreProductParameterITunesItemIdentifier:identifier}; + + [storeViewController loadProductWithParameters:parameters + completionBlock:^(BOOL result, NSError *error) { + if (result) + [self presentViewController:storeViewController + animated:YES + completion:nil]; + else NSLog(@"SKStoreProductViewController: %@", error); + }]; + + +} + +-(void) composeMail +{ + if([MFMailComposeViewController canSendMail]) { + MFMailComposeViewController* composeVC = [MFMailComposeViewController new]; + composeVC.mailComposeDelegate = self; + [composeVC setToRecipients:@[@"info@monal-im.org"]]; + [self presentViewController:composeVC animated:YES completion:nil]; + } + else + { + UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", @"") message:NSLocalizedString(@"There is no configured email account. Please email info@monal-im.org .", @"") preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction* closeAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + }]; + [messageAlert addAction:closeAction]; + + [self presentViewController:messageAlert animated:YES completion:nil]; + } + +} + +#pragma mark - Message ui delegate +- (void)mailComposeController:(MFMailComposeViewController *)controller + didFinishWithResult:(MFMailComposeResult)result + error:(NSError *)error +{ + [controller dismissViewControllerAnimated:YES completion:nil]; +} + + +#pragma mark - SKStoreProductViewControllerDelegate + +-(void)productViewControllerDidFinish:(SKStoreProductViewController *)viewController { + [viewController dismissViewControllerAnimated:YES completion:nil]; +} +@end diff --git a/Monal/Classes/MLSignalStore.h b/Monal/Classes/MLSignalStore.h new file mode 100644 index 0000000..837a85c --- /dev/null +++ b/Monal/Classes/MLSignalStore.h @@ -0,0 +1,76 @@ +// +// MLSignalStore.h +// Monal +// +// Created by Anurodh Pokharel on 5/3/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import +@import SignalProtocolObjC; + +#define MLOmemoInternalNotTrusted 0 +#define MLOmemoInternalToFU 1 +#define MLOmemoInternalTrusted 2 + +#define MLOmemoNotTrusted 0 +#define MLOmemoToFU 100 +#define MLOmemoToFUButRemoved 101 +#define MLOmemoToFUButNoMsgSeenInTime 102 +#define MLOmemoTrusted 200 +#define MLOmemoTrustedButRemoved 201 +#define MLOmemoTrustedButNoMsgSeenInTime 202 + +@interface MLSignalStore : NSObject +@property (nonatomic, assign) u_int32_t deviceid; +@property (nonatomic, assign) NSString* _Nonnull accountJid; +@property (nonatomic, strong) SignalIdentityKeyPair* _Nullable identityKeyPair; +@property (nonatomic, strong) SignalSignedPreKey* _Nullable signedPreKey; +@property (nonatomic, strong) NSArray* _Nullable preKeys; + +-(MLSignalStore* _Nonnull) initWithAccountID:(NSNumber* _Nonnull) accountID andAccountJid:(NSString* _Nonnull) accountJid; +-(void) saveValues; + +-(NSData* _Nullable) getIdentityForAddress:(SignalAddress* _Nonnull) address; +-(BOOL) saveIdentity:(SignalAddress* _Nonnull) address identityKey:(NSData* _Nullable) identityKey; +/** + all non deleted devices (even those without sessions or a broken session) + */ +-(NSArray* _Nullable) knownDevicesForAddressName:(NSString* _Nullable) addressName; +/** + all non deleted devices with a valid (non broken) session + */ +-(NSArray* _Nonnull) knownDevicesWithValidSession:(NSString* _Nonnull) jid; +/** + * all non deleted devices with a broken sessions where a bundle fetch is advised + */ +-(NSArray* _Nonnull) knownDevicesWithPendingBrokenSessionHandling:(NSString* _Nonnull) jid; + +-(NSMutableArray* _Nonnull) readPreKeys; + +-(void) deleteDeviceforAddress:(SignalAddress* _Nonnull) address; + +-(void) markDeviceAsDeleted:(SignalAddress* _Nonnull) address; +-(void) removeDeviceDeletedMark:(SignalAddress* _Nonnull) address; +-(void) updateLastSuccessfulDecryptTime:(SignalAddress* _Nonnull) address; +-(void) markSessionAsBroken:(SignalAddress* _Nonnull) address; +-(void) markBundleAsFixed:(SignalAddress* _Nonnull) address; +-(BOOL) isSessionBrokenForJid:(NSString* _Nonnull) jid andDeviceId:(NSNumber* _Nonnull) deviceId; +-(void) markBundleAsBroken:(SignalAddress* _Nonnull) address; + +// MUC session management +-(BOOL) sessionsExistForBuddy:(NSString* _Nonnull) buddyJid; +-(BOOL) checkIfSessionIsStillNeeded:(NSString* _Nonnull) buddyJid; +-(NSSet* _Nonnull) removeDanglingMucSessions; + +-(void) updateTrust:(BOOL) trust forAddress:(SignalAddress* _Nonnull) address; +-(int) getInternalTrustLevel:(SignalAddress* _Nonnull) address identityKey:(NSData* _Nonnull) identityKey; +-(void) untrustAllDevicesFrom:(NSString* _Nonnull) jid; +-(NSNumber* _Nonnull) getTrustLevel:(SignalAddress* _Nonnull) address identityKey:(NSData* _Nonnull) identityKey; + +-(int) getHighestPreyKeyId; +-(unsigned int) getPreKeyCount; + +-(void) cleanupKeys; +-(void) reloadCachedPrekeys; +@end diff --git a/Monal/Classes/MLSignalStore.m b/Monal/Classes/MLSignalStore.m new file mode 100644 index 0000000..d527fb7 --- /dev/null +++ b/Monal/Classes/MLSignalStore.m @@ -0,0 +1,680 @@ +// +// MLSignalStore.m +// Monal +// +// Created by Anurodh Pokharel on 5/3/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import "MLConstants.h" +#import "MLSignalStore.h" +#import "SignalProtocolObjC.h" +#import "DataLayer.h" +#import "MLSQLite.h" +#import "HelperTools.h" + +@interface MLSignalStore() +{ + NSString* _dbPath; +} +@property (nonatomic, strong) NSNumber* accountID; +@property (readonly, strong) MLSQLite* sqliteDatabase; +@end + +@implementation MLSignalStore + ++(void) initialize +{ + //TODO: WE USE THE SAME DATABASE FILE AS THE DataLayer --> this should probably be migrated into the datalayer or use its own sqlite database + + //make sure the datalayer has migrated the database file to the app group location first + [DataLayer initialize]; +} + +//this is the getter of our readonly "sqliteDatabase" property always returning the thread-local instance of the MLSQLite class +-(MLSQLite*) sqliteDatabase +{ + //always return thread-local instance of sqlite class (this is important for performance!) + return [MLSQLite sharedInstanceForFile:_dbPath]; +} + +-(MLSignalStore*) initWithAccountID:(NSNumber*) accountID andAccountJid:(NSString* _Nonnull) accountJid +{ + self = [super init]; + _dbPath = [[HelperTools getContainerURLForPathComponents:@[@"sworim.sqlite"]] path]; + + self.accountID = accountID; + NSArray* data = [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeReader:@"SELECT identityPrivateKey, deviceid, identityPublicKey FROM signalIdentity WHERE account_id=?;" andArguments:@[accountID]]; + }]; + NSDictionary* row = [data firstObject]; + + if(row) + { + self.deviceid = [(NSNumber *)[row objectForKey:@"deviceid"] unsignedIntValue]; + self.accountJid = accountJid; + + NSData* idKeyPub = [row objectForKey:@"identityPublicKey"]; + NSData* idKeyPrivate = [row objectForKey:@"identityPrivateKey"]; + + NSError* error; + self.identityKeyPair = [[SignalIdentityKeyPair alloc] initWithPublicKey:idKeyPub privateKey:idKeyPrivate error:nil]; + if(error) + { + DDLogError(@"prekey error %@", error); + return self; + } + + self.signedPreKey = [[SignalSignedPreKey alloc] initWithSerializedData:[self loadSignedPreKeyWithId:1] error:&error]; + if(error) + { + DDLogError(@"signed prekey error %@", error); + return self; + } + // remove old keys that should no longer be available + [self cleanupKeys]; + [self reloadCachedPrekeys]; + } + else + self.deviceid = 0; + + return self; +} + +-(void) reloadCachedPrekeys +{ + self.preKeys = [self readPreKeys]; +} + +-(void) cleanupKeys +{ + [self.sqliteDatabase voidWriteTransaction:^{ + // remove old keys that have been remove a long time ago from pubsub + [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalPreKey WHERE account_id=? AND pubSubRemovalTimestamp IS NOT NULL AND pubSubRemovalTimestamp <= unixepoch('now', '-14 day');" andArguments:@[self.accountID]]; + // mark old unused keys to be removed from pubsub + [self.sqliteDatabase executeNonQuery:@"UPDATE signalPreKey SET pubSubRemovalTimestamp=CURRENT_TIMESTAMP WHERE account_id=? AND keyUsed=0 AND pubSubRemovalTimestamp IS NULL AND creationTimestamp<= unixepoch('now','-14 day');" andArguments:@[self.accountID]]; + }]; +} + +-(NSMutableArray*) readPreKeys +{ + NSArray* keys = [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeReader:@"SELECT prekeyid, preKey FROM signalPreKey WHERE account_id=? AND keyUsed=0;" andArguments:@[self.accountID]]; + }]; + + NSMutableArray* array = [[NSMutableArray alloc] initWithCapacity:keys.count]; + for (NSDictionary* row in keys) + { + SignalPreKey* key = [[SignalPreKey alloc] initWithSerializedData:[row objectForKey:@"preKey"] error:nil]; + [array addObject:key]; + } + return array; +} + +-(int) getHighestPreyKeyId +{ + NSNumber* highestId = [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:@"SELECT prekeyid FROM signalPreKey WHERE account_id=? ORDER BY prekeyid DESC LIMIT 1;" andArguments:@[self.accountID]]; + }]; + + if(highestId == nil) { + return 0; // Default value -> first preKeyId will be 1 + } else { + return highestId.intValue; + } +} + +-(unsigned int) getPreKeyCount +{ + NSNumber* count = [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:@"SELECT COUNT(prekeyid) FROM signalPreKey WHERE account_id=? AND pubSubRemovalTimestamp IS NULL AND keyUsed=0;" andArguments:@[self.accountID]]; + }]; + return count.unsignedIntValue; +} + +-(void) saveValues +{ + [self storeSignedPreKey:self.signedPreKey.serializedData signedPreKeyId:1]; + [self storeIdentityPublicKey:self.identityKeyPair.publicKey andPrivateKey:self.identityKeyPair.privateKey]; + + for (SignalPreKey *key in self.preKeys) + { + [self storePreKey:key.serializedData preKeyId:key.preKeyId]; + } + [self reloadCachedPrekeys]; +} + +/** + * Returns a copy of the serialized session record corresponding to the + * provided recipient ID + device ID tuple. + * or nil if not found. + */ +- (NSData* _Nullable) sessionRecordForAddress:(SignalAddress*) address +{ + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:@"SELECT recordData FROM signalContactSession WHERE account_id=? AND contactName=? AND contactDeviceId=?;" andArguments:@[self.accountID, address.name, [NSNumber numberWithInteger:address.deviceId]]]; + }]; +} + +/** + * Commit to storage the session record for a given + * recipient ID + device ID tuple. + * + * Return YES on success, NO on failure. + */ +-(BOOL) storeSessionRecord:(NSData*) recordData forAddress:(SignalAddress*) address +{ + return [self.sqliteDatabase boolWriteTransaction:^{ + if([self sessionRecordForAddress:address]) + return [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactSession SET recordData=? WHERE account_id=? AND contactName=? AND contactDeviceId=?;" andArguments:@[recordData, self.accountID, address.name, [NSNumber numberWithInteger:address.deviceId]]]; + else + return [self.sqliteDatabase executeNonQuery:@"INSERT INTO signalContactSession (account_id, contactName, contactDeviceId, recordData) VALUES (?, ?, ?, ?);" andArguments:@[self.accountID, address.name, [NSNumber numberWithInteger:address.deviceId], recordData]]; + }]; +} + +/** + * Determine whether there is a committed session record for a + * recipient ID + device ID tuple. + */ +- (BOOL) sessionRecordExistsForAddress:(SignalAddress*) address; +{ + NSData* preKeyData = [self sessionRecordForAddress:address]; + return preKeyData ? YES : NO; +} + +/** + * Remove a session record for a recipient ID + device ID tuple. + */ +- (BOOL) deleteSessionRecordForAddress:(SignalAddress*) address +{ + return [self.sqliteDatabase boolWriteTransaction:^{ + return [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalContactSession WHERE account_id=? AND contactName=? AND contactDeviceId=?;" andArguments:@[self.accountID, address.name, [NSNumber numberWithInteger:address.deviceId]]]; + }]; +} + +/** + * Returns all known devices with active sessions for a recipient + */ +- (NSArray*) allDeviceIdsForAddressName:(NSString*) jid +{ + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalarReader:@"SELECT DISTINCT contactDeviceId FROM signalContactSession WHERE account_id=? AND contactName=?;" andArguments:@[self.accountID, jid]]; + }]; +} + +-(NSArray*) knownDevicesForAddressName:(NSString*) jid +{ + if(!jid) + return nil; + + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalarReader:@"SELECT DISTINCT contactDeviceId FROM signalContactIdentity WHERE account_id=? AND contactName=? AND removedFromDeviceList IS NULL;" andArguments:@[self.accountID, jid]]; + }]; +} + +-(NSArray*) knownDevicesWithValidSession:(NSString*) jid +{ + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalarReader:@"\ + SELECT DISTINCT \ + contactDeviceId \ + FROM signalContactIdentity \ + WHERE \ + account_id=? \ + AND contactName=? \ + AND removedFromDeviceList IS NULL \ + AND brokenSession=false \ + ;" andArguments:@[self.accountID, jid]]; + }]; +} + +-(NSArray*) knownDevicesWithPendingBrokenSessionHandling:(NSString*) jid +{ + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalarReader:@"\ + SELECT DISTINCT \ + contactDeviceId \ + FROM signalContactIdentity \ + WHERE \ + account_id=? \ + AND contactName=? \ + AND removedFromDeviceList IS NULL \ + AND brokenSession=true \ + AND (lastFailedBundleFetch IS NULL OR lastFailedBundleFetch <= unixepoch('now', '-5 day'))\ + ;" andArguments:@[self.accountID, jid]]; + }]; +} + +/** + * Remove the session records corresponding to all devices of a recipient ID. + * + * @return the number of deleted sessions on success, negative on failure + */ +-(int) deleteAllSessionsForAddressName:(NSString*) addressName +{ + return [[self.sqliteDatabase idWriteTransaction:^{ + NSNumber* count = (NSNumber*) [self.sqliteDatabase executeScalar:@"COUNT * FROM signalContactSession WHERE account_id=? AND contactName=?;" andArguments:@[self.accountID, addressName]]; + [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalContactSession WHERE account_id=? AND contactName=?;" andArguments:@[self.accountID, addressName]]; + return count; + }] intValue]; +} + +/** + * Load a local serialized PreKey record. + * return nil if not found + */ +- (nullable NSData*) loadPreKeyWithId:(uint32_t) preKeyId; +{ + DDLogDebug(@"Loading prekey %lu", (unsigned long)preKeyId); + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:@"SELECT prekey FROM signalPreKey WHERE account_id=? AND prekeyid=? AND keyUsed=0;" andArguments:@[self.accountID, [NSNumber numberWithInteger:preKeyId]]]; + }]; +} + +/** + * Store a local serialized PreKey record. + * return YES if storage successful, else NO + */ +-(BOOL) storePreKey:(NSData*) preKey preKeyId:(uint32_t) preKeyId +{ + DDLogDebug(@"Storing prekey %lu", (unsigned long)preKeyId); + return [self.sqliteDatabase boolWriteTransaction:^{ + // Only store new preKeys + NSNumber* preKeyCnt = [self.sqliteDatabase executeScalar:@"SELECT count(*) FROM signalPreKey WHERE account_id=? AND prekeyid=? AND preKey=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:preKeyId], preKey]]; + if(preKeyCnt.intValue > 0) + return YES; + return [self.sqliteDatabase executeNonQuery:@"INSERT INTO signalPreKey (account_id, prekeyid, preKey) VALUES (?, ?, ?);" andArguments:@[self.accountID, [NSNumber numberWithInteger:preKeyId], preKey]]; + }]; +} + +/** + * Determine whether there is a committed PreKey record matching the + * provided ID. + */ +-(BOOL) containsPreKeyWithId:(uint32_t) preKeyId; +{ + NSData* prekey = [self loadPreKeyWithId:preKeyId]; + return prekey ? YES : NO; +} + +/** + * Delete a PreKey record from local storage. + */ +-(BOOL) deletePreKeyWithId:(uint32_t) preKeyId +{ + DDLogDebug(@"Marking prekey %lu as deleted", (unsigned long)preKeyId); + // only mark the key for deletion -> key should be removed from pubSub + return [self.sqliteDatabase boolWriteTransaction:^{ + BOOL ret = [self.sqliteDatabase executeNonQuery:@"UPDATE signalPreKey SET pubSubRemovalTimestamp=CURRENT_TIMESTAMP, keyUsed=1 WHERE account_id=? AND prekeyid=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:preKeyId]]]; + [self reloadCachedPrekeys]; + return ret; + }]; +} + +/** + * Load a local serialized signed PreKey record. + */ +-(nullable NSData*) loadSignedPreKeyWithId:(uint32_t) signedPreKeyId +{ + DDLogDebug(@"Loading signed prekey %lu", (unsigned long)signedPreKeyId); + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:@"SELECT signedPreKey FROM signalSignedPreKey WHERE account_id=? AND signedPreKeyId=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:signedPreKeyId]]]; + }]; +} + +/** + * Store a local serialized signed PreKey record. + */ +- (BOOL) storeSignedPreKey:(NSData*) signedPreKey signedPreKeyId:(uint32_t) signedPreKeyId +{ + DDLogDebug(@"Storing signed prekey %lu", (unsigned long)signedPreKeyId); + return [self.sqliteDatabase boolWriteTransaction:^{ + return [self.sqliteDatabase executeNonQuery:@"INSERT INTO signalSignedPreKey (account_id, signedPreKeyId, signedPreKey) VALUES (?, ?, ?) ON CONFLICT(account_id, signedPreKeyId) DO UPDATE SET signedPreKey=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:signedPreKeyId], signedPreKey, signedPreKey]]; + }]; +} + +/** + * Determine whether there is a committed signed PreKey record matching + * the provided ID. + */ +- (BOOL) containsSignedPreKeyWithId:(uint32_t) signedPreKeyId +{ + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:@"SELECT signedPreKey FROM signalSignedPreKey WHERE account_id=? AND signedPreKeyId=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:signedPreKeyId]]]; + }] ? YES : NO; +} + +/** + * Delete a SignedPreKeyRecord from local storage. + */ +- (BOOL) removeSignedPreKeyWithId:(uint32_t) signedPreKeyId +{ + DDLogDebug(@"Removing signed prekey %lu", (unsigned long)signedPreKeyId); + return [self.sqliteDatabase boolWriteTransaction:^{ + return [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalSignedPreKey WHERE account_id=? AND signedPreKeyId=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:signedPreKeyId]]]; + }]; +} + +/** + * Get the local client's identity key pair. + */ +-(SignalIdentityKeyPair*) getIdentityKeyPair; +{ + return self.identityKeyPair; +} + +-(BOOL) identityPublicKeyExists:(NSData*) publicKey andPrivateKey:(NSData *) privateKey +{ + return [[self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:@"SELECT COUNT(*) FROM signalIdentity WHERE account_id=? AND deviceid=? AND identityPublicKey=? AND identityPrivateKey=?;" andArguments:@[self.accountID, [NSNumber numberWithUnsignedInt:self.deviceid], publicKey, privateKey]]; + }] boolValue]; +} + +- (BOOL) storeIdentityPublicKey:(NSData*) publicKey andPrivateKey:(NSData*) privateKey +{ + return [self.sqliteDatabase boolWriteTransaction:^{ + if([self identityPublicKeyExists:publicKey andPrivateKey:privateKey]) + return YES; + + return [self.sqliteDatabase executeNonQuery:@"INSERT INTO signalIdentity (account_id, deviceid, identityPublicKey, identityPrivateKey) values (?, ?, ?, ?);" andArguments:@[self.accountID, [NSNumber numberWithInteger:self.deviceid], publicKey, privateKey]]; + }]; +} + + +/** + * Return the local client's registration ID. + * + * Clients should maintain a registration ID, a random number + * between 1 and 16380 that's generated once at install time. + * + * return negative on failure + */ +- (uint32_t) getLocalRegistrationId; +{ + return self.deviceid; +} + +/** + * Save a remote client's identity key + *

+ * Store a remote client's identity key as trusted. + * The value of key_data may be null. In this case remove the key data + * from the identity store, but retain any metadata that may be kept + * alongside it. + */ +-(BOOL) saveIdentity:(SignalAddress* _Nonnull) address identityKey:(NSData* _Nullable) identityKey; +{ + return [self.sqliteDatabase boolWriteTransaction:^{ + NSData* dbIdentity= (NSData *)[self.sqliteDatabase executeScalar:@"SELECT IDENTITY FROM signalContactIdentity WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + if(dbIdentity) + return YES; + // Set every new identity to TOFU to increase user experience + return [self.sqliteDatabase executeNonQuery:@"INSERT INTO signalContactIdentity \ + (account_id, contactName, contactDeviceId, identity, trustLevel) \ + VALUES (?, ?, ?, ?, ?)" andArguments:@[self.accountID, address.name, [NSNumber numberWithInteger:address.deviceId], identityKey, [NSNumber numberWithInt:MLOmemoInternalToFU]]]; + }]; +} + +/** + * Verify a remote client's identity key. + * + * Determine whether a remote client's identity is trusted. Convention is + * that the TextSecure protocol is 'trust on first use.' This means that + * an identity key is considered 'trusted' if there is no entry for the recipient + * in the local store, or if it matches the saved key for a recipient in the local + * store. Only if it mismatches an entry in the local store is it considered + * 'untrusted.' + */ +-(BOOL) isTrustedIdentity:(SignalAddress*) address identityKey:(NSData*) identityKey; +{ + int trustLevel = [self getTrustLevel:address identityKey:identityKey].intValue; + // For better UX trust ToFU devices even if we did not receive msg in a long time + return (trustLevel == MLOmemoTrusted || trustLevel == MLOmemoToFU || trustLevel == MLOmemoToFUButNoMsgSeenInTime); +} + +-(NSData*) getIdentityForAddress:(SignalAddress*) address +{ + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:@"SELECT identity FROM signalContactIdentity WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; +} + +/* + * ToFU independent trust update + * true -> trust + * false -> don't trust + * -> after calling updateTrust once ToFU will be over + */ +-(void) updateTrust:(BOOL) trust forAddress:(SignalAddress*) address +{ + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET trustLevel=? WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[[NSNumber numberWithInt:(trust ? MLOmemoInternalTrusted : MLOmemoInternalNotTrusted)], self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; +} + +-(void) markDeviceAsDeleted:(SignalAddress*) address +{ + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET removedFromDeviceList=CURRENT_TIMESTAMP WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; +} + +-(void) removeDeviceDeletedMark:(SignalAddress*) address +{ + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET removedFromDeviceList=NULL WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; +} + +/* + * update lastReceivedMsg to CURRENT_TIMESTAMP + * reset brokenSession to false + */ +-(void) updateLastSuccessfulDecryptTime:(SignalAddress*) address +{ + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET lastReceivedMsg=CURRENT_TIMESTAMP, brokenSession=false WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; +} + +-(void) markSessionAsBroken:(SignalAddress*) address +{ + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET brokenSession=true WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; +} + +-(void) markBundleAsFixed:(SignalAddress*) address +{ + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET brokenSession=false, lastFailedBundleFetch=NULL WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; +} + +-(void) markBundleAsBroken:(SignalAddress*) address +{ + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET lastFailedBundleFetch=unixepoch('now') WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; +} + +-(BOOL) isSessionBrokenForJid:(NSString*) jid andDeviceId:(NSNumber*) deviceId +{ + return [self.sqliteDatabase boolReadTransaction:^{ + return [[self.sqliteDatabase executeScalar:@"SELECT brokenSession FROM signalContactIdentity WHERE account_id=? AND contactDeviceId=? AND contactName=?;" andArguments:@[self.accountID, deviceId, jid]] boolValue]; + }]; +} + +-(int) getInternalTrustLevel:(SignalAddress*) address identityKey:(NSData*) identityKey +{ + return [[self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:(@"SELECT trustLevel FROM signalContactIdentity WHERE account_id=? AND contactDeviceId=? AND contactName=? AND identity=?;") andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name, identityKey]]; + }] intValue]; +} + +-(void) untrustAllDevicesFrom:(NSString*) jid +{ + if([jid isEqualToString:self.accountJid] == NO) + { + // untrust all devices + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET trustLevel=? WHERE account_id=? AND contactName=?;" andArguments:@[[NSNumber numberWithInt:MLOmemoInternalNotTrusted], self.accountID, jid]]; + }]; + } + else + { + // untrust all of our own devices except our own device id + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"UPDATE signalContactIdentity SET trustLevel=? WHERE account_id=? AND contactName=? AND contactDeviceId!=?;" andArguments:@[[NSNumber numberWithInt:MLOmemoInternalNotTrusted], self.accountID, jid, [NSNumber numberWithUnsignedInt:self.deviceid]]]; + }]; + } +} + +-(NSNumber*) getTrustLevel:(SignalAddress*) address identityKey:(NSData*) identityKey +{ + return [self.sqliteDatabase idReadTransaction:^{ + return [self.sqliteDatabase executeScalar:(@"SELECT \ + CASE \ + WHEN (trustLevel=0) THEN 0 \ + WHEN (trustLevel=1 AND removedFromDeviceList IS NULL AND (lastReceivedMsg IS NULL OR lastReceivedMsg >= unixepoch('now', '-90 day'))) THEN 100 \ + WHEN (trustLevel=1 AND removedFromDeviceList IS NOT NULL) THEN 101 \ + WHEN (trustLevel=1 AND removedFromDeviceList IS NULL AND (lastReceivedMsg < unixepoch('now', '-90 day'))) THEN 102 \ + WHEN (COUNT(*)=0) THEN 100 \ + WHEN (trustLevel=2 AND removedFromDeviceList IS NULL AND (lastReceivedMsg IS NULL OR lastReceivedMsg >= unixepoch('now', '-90 day'))) THEN 200 \ + WHEN (trustLevel=2 AND removedFromDeviceList IS NOT NULL) THEN 201 \ + WHEN (trustLevel=2 AND removedFromDeviceList IS NULL AND (lastReceivedMsg < unixepoch('now', '-90 day'))) THEN 202 \ + ELSE 0 \ + END \ + FROM signalContactIdentity \ + WHERE account_id=? AND contactDeviceId=? AND contactName=? AND identity=?; \ + ") andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name, identityKey]]; + }]; +} + +-(void) deleteDeviceforAddress:(SignalAddress*) address +{ + [self.sqliteDatabase voidWriteTransaction:^{ + [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalContactIdentity WHERE account_id=? AND contactDeviceId=? AND contactName=?" andArguments:@[self.accountID, [NSNumber numberWithInteger:address.deviceId], address.name]]; + }]; + } + +// MUC session management + +// return true if we found at least one fingerprint for the given buddyJid +-(BOOL) sessionsExistForBuddy:(NSString*) buddyJid +{ + return [self.sqliteDatabase boolWriteTransaction:^{ + NSNumber* contactDevicesExist = [self.sqliteDatabase executeScalar:@"SELECT COUNT(contactDeviceId) FROM signalContactIdentity WHERE account_id=? AND contactName=?;" andArguments:@[self.accountID, buddyJid]]; + return (BOOL)(contactDevicesExist.intValue > 0); + }]; +} + +// delete the fingerprints and session for the given buddyJid if the jid is neither a 1:1 buddy nor a group member +-(BOOL) checkIfSessionIsStillNeeded:(NSString*) buddyJid +{ + return [self.sqliteDatabase boolWriteTransaction:^{ + // delete fingerprints from buddyJid if the buddyJid is neither a buddy, a self chat, nor a group member + NSNumber* buddyJidCnt = [self.sqliteDatabase executeScalar:@"SELECT \ + (bCnt.buddyListCnt + mucCnt.roomCnt + accountCnt) \ + FROM \ + ( \ + SELECT \ + COUNT(buddy_name) AS buddyListCnt \ + FROM buddylist \ + WHERE \ + account_id=? \ + AND buddy_name=? \ + AND Muc=0 \ + ) AS bCnt, \ + ( \ + SELECT \ + COUNT(m.room) AS roomCnt \ + FROM muc_members AS m \ + INNER JOIN buddylist AS b \ + ON m.account_id = b.account_id \ + AND b.buddy_name = m.member_jid \ + WHERE \ + b.account_id=? \ + AND m.member_jid=? \ + AND b.Muc=1 \ + AND b.muc_type='group' \ + ) AS mucCnt, \ + ( \ + SELECT \ + COUNT(account_id) AS accountCnt \ + FROM account \ + WHERE \ + (username || '@' || domain) = ? \ + AND account_id = ? \ + ) AS accountCnt" andArguments:@[self.accountID, buddyJid, self.accountID, buddyJid, self.accountID, buddyJid]]; + + BOOL buddyStillNeeded = buddyJidCnt.intValue > 0; + if(buddyStillNeeded == NO) + { + [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalContactIdentity \ + WHERE \ + account_id=? \ + AND contactName=? \ + " andArguments:@[self.accountID, buddyJid]]; + [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalContactSession \ + WHERE \ + account_id=? \ + AND contactName=? \ + " andArguments:@[self.accountID, buddyJid]]; + } + return buddyStillNeeded; + }]; +} + +// delete all fingerprints and sessions from contacts that are neither a buddy nor a group member +// return jids of dangling sessions +-(NSSet*) removeDanglingMucSessions +{ + return [self.sqliteDatabase idWriteTransaction:^{ + // create a list of all sessions + NSArray* jidsWithSession = [self.sqliteDatabase executeScalarReader:@"SELECT DISTINCT contactName \ + FROM signalContactIdentity \ + WHERE \ + account_id = ? \ + " andArguments:@[self.accountID]]; + NSMutableSet* danglingJids = [NSMutableSet new]; + for(NSString* jid in jidsWithSession) { + // check if the session is still needed + if([self checkIfSessionIsStillNeeded:jid] == NO) { + [danglingJids addObject:jid]; + // delete old session + [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalContactIdentity \ + WHERE \ + account_id = ? \ + AND contactName = ? \ + " andArguments:@[self.accountID, jid]]; + [self.sqliteDatabase executeNonQuery:@"DELETE FROM signalContactSession \ + WHERE \ + account_id = ? \ + AND contactName = ? \ + " andArguments:@[self.accountID, jid]]; + } + } + return danglingJids; + }]; +} + +/** + * Store a serialized sender key record for a given + * (groupId + senderId + deviceId) tuple. + */ +-(BOOL) storeSenderKey:(nonnull NSData*) senderKey address:(nonnull SignalAddress*) address groupId:(nonnull NSString*) groupId; +{ + return false; +} + +/** + * Returns a copy of the sender key record corresponding to the + * (groupId + senderId + deviceId) tuple. + */ +- (nullable NSData*) loadSenderKeyForAddress:(SignalAddress*) address groupId:(NSString*) groupId; +{ + return nil; +} + +@end diff --git a/Monal/Classes/MLSoundsTableViewController.h b/Monal/Classes/MLSoundsTableViewController.h new file mode 100644 index 0000000..ec8f0ad --- /dev/null +++ b/Monal/Classes/MLSoundsTableViewController.h @@ -0,0 +1,18 @@ +// +// MLSoundsTableViewController.h +// Monal +// +// Created by Anurodh Pokharel on 11/27/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLSoundsTableViewController : UITableViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLSoundsTableViewController.m b/Monal/Classes/MLSoundsTableViewController.m new file mode 100644 index 0000000..a0a75d8 --- /dev/null +++ b/Monal/Classes/MLSoundsTableViewController.m @@ -0,0 +1,142 @@ +// +// MLSoundsTableViewController.m +// Monal +// +// Created by Anurodh Pokharel on 11/27/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import "HelperTools.h" +#import "MLSoundsTableViewController.h" +#import "MLSettingCell.h" +#import "MLImageManager.h" +@import AVFoundation; + +@interface MLSoundsTableViewController () +@property (nonatomic, strong) NSArray* soundList; +@property (nonatomic, assign) NSInteger selectedIndex; +@property (nonatomic, strong) AVAudioPlayer* audioPlayer; +@end + +@implementation MLSoundsTableViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = NSLocalizedString(@"Sounds", @""); + self.soundList = @[ + @"System Sound", + @"Morse", + @"Xylophone", + @"Bloop", + @"Bing", + @"Pipa", + @"Water", + @"Forest", + @"Echo", + @"Area 51", + @"Wood", + @"Chirp", + @"Sonar", + ]; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +#pragma mark - Table view data source + +-(NSInteger) numberOfSectionsInTableView:(UITableView*) tableView +{ + return 2; +} + +-(NSInteger) tableView:(UITableView*) tableView numberOfRowsInSection:(NSInteger) section +{ + if(section == 0) + return 1; + else + return (NSInteger)self.soundList.count; +} + +-(NSString*) tableView:(UITableView*) tableView titleForHeaderInSection:(NSInteger) section +{ + if(section == 1) + return NSLocalizedString(@"Select sounds that are played with new message notifications. Default is Xylophone.", @""); + return nil; +} + +-(NSString*) tableView:(UITableView*) tableView titleForFooterInSection:(NSInteger) section +{ + if(section == 1) + return NSLocalizedString(@"Sounds courtesy Emrah", @""); + return nil; +} + +-(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath +{ + if(indexPath.section == 0) + { + MLSettingCell* cell = [[MLSettingCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"AccountCell"]; + cell.parent = self; + cell.switchEnabled = YES; + cell.defaultKey = @"Sound"; + cell.textLabel.text = NSLocalizedString(@"Play Sounds", @""); + return cell; + } + else + { + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:@"soundCell"]; + cell.textLabel.text = self.soundList[(NSUInteger)indexPath.row]; + NSString* filename = [NSString stringWithFormat:@"alert%ld", (long)indexPath.row]; + if( + (indexPath.row == 0 && [[HelperTools defaultsDB] objectForKey:@"AlertSoundFile"] == nil) || + [filename isEqualToString:[[HelperTools defaultsDB] objectForKey:@"AlertSoundFile"]] + ) + { + cell.accessoryType = UITableViewCellAccessoryCheckmark; + self.selectedIndex = indexPath.row; + } + else + { + cell.accessoryType = UITableViewCellAccessoryNone; + } + return cell; + } +} + + +-(void) playSound:(NSInteger ) index +{ + NSString* filename = [NSString stringWithFormat:@"alert%ld", (long)index]; + NSURL* url = [[NSBundle mainBundle] URLForResource:filename withExtension:@"aif" subdirectory:@"AlertSounds"]; + self.audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:NULL]; + [self.audioPlayer play]; +} + +-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:NO]; + if(indexPath.section==0) + return; + + NSString* filename = nil; + if(indexPath.row > 0) + { + [self playSound:indexPath.row]; + filename = [NSString stringWithFormat:@"alert%ld", (long)indexPath.row]; + [[HelperTools defaultsDB] setObject:filename forKey:@"AlertSoundFile"]; + } + else + [[HelperTools defaultsDB] removeObjectForKey:@"AlertSoundFile"]; + NSIndexPath* old = [NSIndexPath indexPathForRow:self.selectedIndex inSection:1]; + self.selectedIndex = indexPath.row; + NSArray* rows = @[old, indexPath]; + [tableView reloadRowsAtIndexPaths:rows withRowAnimation:UITableViewRowAnimationNone]; +} + + +@end + diff --git a/Monal/Classes/MLSplitViewDelegate.h b/Monal/Classes/MLSplitViewDelegate.h new file mode 100644 index 0000000..190de94 --- /dev/null +++ b/Monal/Classes/MLSplitViewDelegate.h @@ -0,0 +1,14 @@ +// +// MLSplitViewDelegate.h +// Monal +// +// Created by Anurodh Pokharel on 12/4/17. +// Copyright © 2017 Monal.im. All rights reserved. +// + +#import +#import + +@interface MLSplitViewDelegate : NSObject + +@end diff --git a/Monal/Classes/MLSplitViewDelegate.m b/Monal/Classes/MLSplitViewDelegate.m new file mode 100644 index 0000000..fe70f3b --- /dev/null +++ b/Monal/Classes/MLSplitViewDelegate.m @@ -0,0 +1,49 @@ +// +// MLSplitViewDelegate.m +// Monal +// +// Created by Anurodh Pokharel on 12/4/17. +// Copyright © 2017 Monal.im. All rights reserved. +// + +#import "MLSplitViewDelegate.h" +#import "ActiveChatsViewController.h" +#import "MLSettingsTableViewController.h" + +@implementation MLSplitViewDelegate + + +#pragma mark - Split view + +-(BOOL) splitViewController:(UISplitViewController*) splitViewController collapseSecondaryViewController:(UIViewController*) secondaryViewController ontoPrimaryViewController:(UIViewController*) primaryViewController +{ + //return YES to indicate that we have handled the collapse by doing nothing; the secondary controller will be discarded. + return YES; +} + +-(void) splitViewControllerDidExpand:(UISplitViewController*) splitViewController +{ + UIViewController* primaryController = ((UINavigationController*)splitViewController.viewControllers[0]).viewControllers[0]; + UIViewController* secondaryController = nil; + if([splitViewController.viewControllers count] > 1) + secondaryController = splitViewController.viewControllers[1]; + + if([primaryController isKindOfClass:NSClassFromString(@"ActiveChatsViewController")]) + [(ActiveChatsViewController*)primaryController updateSizeClass]; + + if([primaryController isKindOfClass:NSClassFromString(@"ActiveChatsViewController")] && [secondaryController isKindOfClass:NSClassFromString(@"MLPlaceholderViewController")]) + [(ActiveChatsViewController*)primaryController presentSplitPlaceholder]; + + if([primaryController isKindOfClass:NSClassFromString(@"MLSettingsTableViewController")] && [secondaryController isKindOfClass:NSClassFromString(@"MLPlaceholderViewController")]) + [(MLSettingsTableViewController*)primaryController presentSplitPlaceholder]; +} + +-(void) splitViewControllerDidCollapse:(UISplitViewController*) splitViewController +{ + UIViewController* primaryController = ((UINavigationController*)splitViewController.viewControllers[0]).viewControllers[0]; + + if([primaryController isKindOfClass:NSClassFromString(@"ActiveChatsViewController")]) + [(ActiveChatsViewController*)primaryController updateSizeClass]; +} + +@end diff --git a/Monal/Classes/MLStream.h b/Monal/Classes/MLStream.h new file mode 100644 index 0000000..60e07b5 --- /dev/null +++ b/Monal/Classes/MLStream.h @@ -0,0 +1,41 @@ +// +// MLStream.h +// Monal +// +// Created by Thilo Molitor on 11.04.21. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLStream : NSStream + +@property(readonly) NSStreamStatus streamStatus; +@property(nullable, readonly, copy) NSError* streamError; + + ++(void) connectWithSNIDomain:(NSString*) SNIDomain connectHost:(NSString*) host connectPort:(NSNumber*) port tls:(BOOL) tls inputStream:(NSInputStream* _Nullable * _Nonnull) inputStream outputStream:(NSOutputStream* _Nullable * _Nonnull) outputStream logtag:(id _Nullable) logtag; +-(void) startTLS; +@property(readonly) BOOL hasTLS; +@property(readonly) BOOL isTLS13; + +@property(nullable, readonly) NSArray* supportedChannelBindingTypes; +-(NSData* _Nullable) channelBindingDataForType:(NSString* _Nullable) type; +@end + +@interface MLInputStream : MLStream + +@property(readonly) BOOL hasBytesAvailable; + +@end + +@interface MLOutputStream : MLStream + +@property(readonly) BOOL hasSpaceAvailable; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLStream.m b/Monal/Classes/MLStream.m new file mode 100644 index 0000000..c9c29e4 --- /dev/null +++ b/Monal/Classes/MLStream.m @@ -0,0 +1,952 @@ +// +// MLStream.m +// monalxmpp +// +// Created by Thilo Molitor on 11.04.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" +#import "MLStream.h" +#import "HelperTools.h" +#import + +@class MLCrypto; + +#define BUFFER_SIZE 4096 + +@interface MLSharedStreamState : NSObject +@property (atomic, strong) id delegate; +@property (atomic, strong) NSRunLoop* runLoop; +@property (atomic) NSRunLoopMode runLoopMode; +@property (atomic, strong) NSError* error; +@property (atomic) nw_connection_t connection; +@property (atomic) BOOL opening; +@property (atomic) BOOL open; +@property (atomic) BOOL hasTLS; +@property (atomic) nw_parameters_configure_protocol_block_t configure_tls_block; +@property (atomic) nw_framer_t _Nullable framer; +@property (atomic) NSCondition* tlsHandshakeCompleteCondition; +@end + +@interface MLStream() +{ + id _delegate; +} +@property (atomic, strong) MLSharedStreamState* shared_state; +@property (atomic) BOOL open_called; +@property (atomic) BOOL closed; +-(instancetype) initWithSharedState:(MLSharedStreamState*) shared; +-(void) generateEvent:(NSStreamEvent) event; +@end + +@interface MLInputStream() +{ + NSMutableData* _buf; + volatile __block BOOL _reading; + //this semaphore will make sure that at most only one call to nw_connection_receive() or nw_framer_parse_input() is in flight + //we use it as mutex: be careful to never increase it beyond 1!! + //(mutexes can not be unlocked in a thread different from the one it got locked in and NSLock internally uses mutext --> both can not be used) + dispatch_semaphore_t _read_sem; +} +@property (atomic, readonly) void (^incoming_data_handler)(NSData* _Nullable, BOOL, NSError* _Nullable, BOOL); +@end + +@interface MLOutputStream() +{ + volatile __block unsigned long _writing; +} +@end + +@implementation MLSharedStreamState + +-(instancetype) init +{ + self = [super init]; + self.error = nil; + self.opening = NO; + self.open = NO; + self.hasTLS = NO; + self.framer = nil; + self.tlsHandshakeCompleteCondition = [NSCondition new]; + return self; +} + +@end + + +@implementation MLInputStream + +-(instancetype) initWithSharedState:(MLSharedStreamState*) shared +{ + self = [super initWithSharedState:shared]; + _buf = [NSMutableData new]; + _reading = NO; + //(see the comments added to the declaration of this member var) + _read_sem = dispatch_semaphore_create(1); //the first schedule_read call is always allowed + + //this handler will be called by the schedule_read method + //since the framer swallows all data, nw_connection_receive() and the framer cannot race against each other and deliver reordered data + weakify(self); + _incoming_data_handler = ^(NSData* _Nullable content, BOOL is_complete, NSError* _Nullable st_error, BOOL polling_active) { + strongify(self); + if(self == nil) + return; + + DDLogVerbose(@"Incoming data handler called with is_complete=%@, st_error=%@, content=%@", bool2str(is_complete), st_error, content); + @synchronized(self.shared_state) { + self->_reading = NO; + } + BOOL generate_bytes_available_event = NO; + BOOL generate_error_event = NO; + + //handle content received + if(content != NULL) + { + if([content length] > 0) + { + @synchronized(self->_buf) { + [self->_buf appendData:content]; + } + generate_bytes_available_event = YES; + } + } + + //handle errors + if(st_error) + { + //ignore enodata and eagain errors + if([st_error.domain isEqualToString:(__bridge NSString *)kNWErrorDomainPOSIX] && (st_error.code == ENODATA || st_error.code == EAGAIN)) + DDLogWarn(@"Ignoring transient receive error: %@", st_error); + else + { + @synchronized(self.shared_state) { + self.shared_state.error = st_error; + } + generate_error_event = YES; + } + } + + //allow new call to schedule_read + //(see the comments added to the declaration of this member var) + dispatch_semaphore_signal(self->_read_sem); + + //emit events + if(generate_bytes_available_event) + [self generateEvent:NSStreamEventHasBytesAvailable]; + if(generate_error_event) + [self generateEvent:NSStreamEventErrorOccurred]; + //check if we're read-closed and stop our loop if true (this has to be done *after* processing content) + if(is_complete) + [self generateEvent:NSStreamEventEndEncountered]; + + //try to read again + if(!is_complete && !generate_error_event && !generate_bytes_available_event && polling_active) + [self schedule_read]; + }; + return self; +} + +-(NSInteger) read:(uint8_t*) buffer maxLength:(NSUInteger) len +{ + @synchronized(self.shared_state) { + if(self.closed || !self.open_called || !self.shared_state.open) + return -1; + } + BOOL was_smaller = NO; + @synchronized(self->_buf) { + if(len > [_buf length]) + len = [_buf length]; + [_buf getBytes:buffer length:len]; + if(len < [_buf length]) + { + NSData* to_append = [_buf subdataWithRange:NSMakeRange(len, [_buf length]-len)]; + [_buf setLength:0]; + [_buf appendData:to_append]; + was_smaller = YES; + } + else + { + [_buf setLength:0]; + was_smaller = NO; + } + } + //this has to be done outside of our @synchronized block + if(was_smaller) + [self generateEvent:NSStreamEventHasBytesAvailable]; + else if(len > 0) //only do this if we really provided some data to the reader + { + //buffered data got retrieved completely --> schedule new read + [self schedule_read]; + } + return len; +} + +-(BOOL) getBuffer:(uint8_t* _Nullable *) buffer length:(NSUInteger*) len +{ + return NO; //this method is not available in this implementation + /* + @synchronized(_buf) { + *len = [_buf length]; + *buffer = (uint8_t* _Nullable)[_buf bytes]; + return YES; + }*/ +} + +-(BOOL) hasBytesAvailable +{ + @synchronized(_buf) { + return _buf && [_buf length]; + } +} + +-(NSStreamStatus) streamStatus +{ + @synchronized(self.shared_state) { + if(self.open_called && self.shared_state.open && _reading) + return NSStreamStatusReading; + } + return [super streamStatus]; +} + +-(void) schedule_read +{ + @synchronized(self.shared_state) { + if(self.closed || !self.open_called || !self.shared_state.open) + { + DDLogVerbose(@"ignoring schedule_read call because connection is closed: %@", self); + return; + } + + //don't call nw_connection_receive() or nw_framer_parse_input() multiple times in parallel: this will introduce race conditions + //(see the comments added to the declaration of this member var) + if(dispatch_semaphore_wait(_read_sem, DISPATCH_TIME_NOW) != 0) + { + DDLogWarn(@"Ignoring call to schedule_read, reading already in progress..."); + return; + } + _reading = YES; + + if(self.shared_state.framer != nil) + { + DDLogDebug(@"dispatching async call to nw_framer_parse_input into framer queue"); + nw_framer_async(self.shared_state.framer, ^{ + DDLogDebug(@"now calling nw_framer_parse_input inside framer queue"); + nw_framer_parse_input(self.shared_state.framer, 1, BUFFER_SIZE, nil, ^size_t(uint8_t* buffer, size_t buffer_length, bool is_complete) { + DDLogDebug(@"nw_framer_parse_input got callback with is_complete:%@, length=%zu", bool2str(is_complete), (unsigned long)buffer_length); + //we don't want to do "polling" here, our next nw_framer_parse_input will be triggered by the nw_framer_set_input_handler block + self.incoming_data_handler([NSData dataWithBytes:buffer length:buffer_length], is_complete, nil, NO); + return buffer_length; + }); + }); + } + else + { + DDLogVerbose(@"calling nw_connection_receive"); + nw_connection_receive(self.shared_state.connection, 1, BUFFER_SIZE, ^(dispatch_data_t content, nw_content_context_t context __unused, bool is_complete, nw_error_t receive_error) { + DDLogVerbose(@"nw_connection_receive got callback with is_complete:%@, receive_error=%@, length=%zu", bool2str(is_complete), receive_error, (unsigned long)((NSData*)content).length); + NSError* st_error = nil; + if(receive_error) + st_error = (NSError*)CFBridgingRelease(nw_error_copy_cf_error(receive_error)); + //we want to do "polling" here (e.g. start our next blocking nw_connection_receive call if we did not receive new data nor any error) + self.incoming_data_handler((NSData*)content, is_complete, st_error, YES); + }); + } + } +} + +-(void) generateEvent:(NSStreamEvent) event +{ + @synchronized(self.shared_state) { + [super generateEvent:event]; + //in contrast to the normal nw_receive, the framer receive will not block until we receive any data + //--> don't call schedule_read if a framer is active, the framer will call it itself once it gets signalled that data is available + if(event == NSStreamEventOpenCompleted && self.open_called && self.shared_state.open && self.shared_state.framer == nil) + { + //we are open now --> allow reading (this will block until we receive any data) + [self schedule_read]; + } + } +} + +@end + +@implementation MLOutputStream + +-(instancetype) initWithSharedState:(MLSharedStreamState*) shared +{ + self = [super initWithSharedState:shared]; + _writing = 0; + return self; +} + +-(NSInteger) write:(const uint8_t*) buffer maxLength:(NSUInteger) len +{ + @synchronized(self.shared_state) { + if(self.closed) + return -1; + } + + NSCondition* condition = [NSCondition new]; + void (^write_completion)(nw_error_t) = ^(nw_error_t _Nullable error) { + DDLogVerbose(@"Write completed..."); + + @synchronized(self) { + self->_writing--; + } + + [condition lock]; + [condition signal]; + [condition unlock]; + + if(error) + { + NSError* st_error = (NSError*)CFBridgingRelease(nw_error_copy_cf_error(error)); + @synchronized(self.shared_state) { + self.shared_state.error = st_error; + } + [self generateEvent:NSStreamEventErrorOccurred]; + } + else + { + @synchronized(self) { + if([self hasSpaceAvailable]) + [self generateEvent:NSStreamEventHasSpaceAvailable]; + } + } + }; + + //the call to dispatch_get_main_queue() is a dummy because we are using DISPATCH_DATA_DESTRUCTOR_DEFAULT which is performed inline + dispatch_data_t data = dispatch_data_create(buffer, len, dispatch_get_main_queue(), DISPATCH_DATA_DESTRUCTOR_DEFAULT); + + //support tcp fast open for all data sent before the connection got opened, but only usable for connections NOT using a framer + /*if(!self.open_called) + { + DDLogInfo(@"Sending TCP fast open early data: %@", data); + nw_connection_send(self.shared_state.connection, data, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, NO, NW_CONNECTION_SEND_IDEMPOTENT_CONTENT); + return len; + }*/ + + @synchronized(self.shared_state) { + if(!self.open_called || !self.shared_state.open) + return -1; + } + @synchronized(self) { + _writing++; + } + + //decide if we should use our framer or normal nw_connection_send() + //framer being nil is the hot path --> make it fast (we'll check if it's still != nil in an @synchronized block below --> still threadsafe + //for the record: wrapping this into an @synchronized block would create a deadlock with our condition wait inside this + //block and the second @synchronized block inside nw_framer_async() + [condition lock]; + if(self.shared_state.framer != nil) + { + DDLogDebug(@"Switching async to framer thread in COLD path..."); + //framer methods must be called inside the framer thread + nw_framer_async(self.shared_state.framer, ^{ + //make sure that self.shared_state.framer still isn't nil, if it is, we fall back to nw_connection_send() + @synchronized(self.shared_state) { + if(self.shared_state.framer != nil) + { + DDLogDebug(@"Calling nw_framer_write_output_data() in COLD path..."); + nw_framer_write_output_data(self.shared_state.framer, data); + //make sure to not call the write_completion inside this @synchronized block + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + write_completion(nil); //TODO: can we detect write errors like in nw_connection_send() somehow? + }); + } + else + { + //make sure to not call nw_connection_send() and the following write_completion inside this @synchronized block + //we don't know if calling nw_connection_send() from the framer thread is safe --> just don't do this to be on the safe side + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + DDLogDebug(@"Calling nw_connection_send() in COLD path..."); + nw_connection_send(self.shared_state.connection, data, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, NO, write_completion); + }); + } + } + }); + //wait for write complete signal + [condition wait]; + [condition unlock]; + DDLogDebug(@"Returning from write in COLD path: %zu", (unsigned long)len); + return len; //return instead of else to leave @synchronized block early + } + DDLogVerbose(@"Calling nw_connection_send() in hot path..."); + nw_connection_send(self.shared_state.connection, data, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, NO, write_completion); + //wait for write complete signal + [condition wait]; + [condition unlock]; + DDLogVerbose(@"Returning from write in hot path: %zu", (unsigned long)len); + return len; +} + +-(BOOL) hasSpaceAvailable +{ + @synchronized(self) { + return self.open_called && self.shared_state.open && !self.closed && _writing == 0; + } +} + +-(NSStreamStatus) streamStatus +{ + @synchronized(self) { + if(self.open_called && self.shared_state.open && !self.closed && _writing > 0) + return NSStreamStatusWriting; + } + return [super streamStatus]; +} + +-(void) generateEvent:(NSStreamEvent) event +{ + @synchronized(self.shared_state) { + [super generateEvent:event]; + //generate the first NSStreamEventHasSpaceAvailable event directly after our NSStreamEventOpenCompleted event + //(the network framework buffers outgoing data itself, e.g. it is always writable) + if(event == NSStreamEventOpenCompleted && [self hasSpaceAvailable]) + [super generateEvent:NSStreamEventHasSpaceAvailable]; + } +} + +@end + +@implementation MLStream + ++(void) connectWithSNIDomain:(NSString*) SNIDomain connectHost:(NSString*) host connectPort:(NSNumber*) port tls:(BOOL) tls inputStream:(NSInputStream* _Nullable * _Nonnull) inputStream outputStream:(NSOutputStream* _Nullable * _Nonnull) outputStream logtag:(id _Nullable) logtag +{ + //create state + volatile __block BOOL wasOpenOnce = NO; + MLSharedStreamState* shared_state = [[MLSharedStreamState alloc] init]; + + //create and configure public stream instances returned later + MLInputStream* input = [[MLInputStream alloc] initWithSharedState:shared_state]; + MLOutputStream* output = [[MLOutputStream alloc] initWithSharedState:shared_state]; + + nw_parameters_configure_protocol_block_t tcp_options = ^(nw_protocol_options_t tcp_options) { + nw_tcp_options_set_enable_fast_open(tcp_options, YES); //enable tcp fast open + //nw_tcp_options_set_no_delay(tcp_options, YES); //disable nagle's algorithm + //nw_tcp_options_set_connection_timeout(tcp_options, 4); + }; + nw_parameters_configure_protocol_block_t configure_tls_block = ^(nw_protocol_options_t tls_options) { + sec_protocol_options_t options = nw_tls_copy_sec_protocol_options(tls_options); + sec_protocol_options_set_tls_server_name(options, [SNIDomain cStringUsingEncoding:NSUTF8StringEncoding]); + sec_protocol_options_add_tls_application_protocol(options, "xmpp-client"); + sec_protocol_options_set_tls_ocsp_enabled(options, 1); + sec_protocol_options_set_tls_false_start_enabled(options, 1); + sec_protocol_options_set_min_tls_protocol_version(options, tls_protocol_version_TLSv12); + //sec_protocol_options_set_max_tls_protocol_version(options, tls_protocol_version_TLSv12); + sec_protocol_options_set_tls_resumption_enabled(options, 1); + sec_protocol_options_set_tls_tickets_enabled(options, 1); + sec_protocol_options_set_tls_renegotiation_enabled(options, 0); + //tls-exporter channel-binding is only usable for TLSv1.2 if ECDHE is used instead of RSA key exchange + //(see https://mitls.org/pages/attacks/3SHAKE) + //see also https://developer.apple.com/documentation/security/preventing_insecure_network_connections?language=objc + sec_protocol_options_append_tls_ciphersuite_group(options, tls_ciphersuite_group_ats); + }; + + //configure tcp connection parameters + nw_parameters_t parameters; + if(tls) + { + parameters = nw_parameters_create_secure_tcp(configure_tls_block, tcp_options); + shared_state.hasTLS = YES; + } + else + { + parameters = nw_parameters_create_secure_tcp(NW_PARAMETERS_DISABLE_PROTOCOL, tcp_options); + shared_state.hasTLS = NO; + + //create simple framer and append it to our stack + //first framer initialization is allowed to send tcp early data + volatile __block int startupCounter = 0; //workaround for some weird apple stuff, see below + nw_protocol_definition_t starttls_framer_definition = nw_framer_create_definition([[[NSUUID UUID] UUIDString] UTF8String], NW_FRAMER_CREATE_FLAGS_DEFAULT, ^(nw_framer_t framer) { + //we don't need any locking for our counter because all framers will be started in the same internal network queue + int framerId = startupCounter++; + DDLogInfo(@"%@: Framer(%d) %@ start called with wasOpenOnce=%@...", logtag, framerId, framer, bool2str(wasOpenOnce)); + nw_framer_set_stop_handler(framer, (nw_framer_stop_handler_t)^(nw_framer_t _Nullable framer) { + DDLogInfo(@"%@, Framer(%d) stop called: %@", logtag, framerId, framer); + return YES; + }); + + /* + //some weird apple stuff creates the framer twice: once directly when starting the tcp handshake + //and again later after the tcp connection was established successfully --> ignore the first one + if(framerId < 1) + { + nw_framer_set_input_handler(framer, ^size_t(nw_framer_t framer) { + nw_framer_parse_input(framer, 1, BUFFER_SIZE, nil, ^size_t(uint8_t* buffer, size_t buffer_length, bool is_complete) { + MLAssert(NO, @"Unexpected incoming bytes in first framer!", (@{ + @"logtag": nilWrapper(logtag), + @"framer": framer, + @"buffer": [NSData dataWithBytes:buffer length:buffer_length], + @"buffer_length": @(buffer_length), + @"is_complete": bool2str(is_complete), + })); + return buffer_length; + }); + return 0; //why that? + }); + nw_framer_set_output_handler(framer, ^(nw_framer_t framer, nw_framer_message_t message, size_t message_length, bool is_complete) { + MLAssert(NO, @"Unexpected outgoing bytes in first framer!", (@{ + @"logtag": nilWrapper(logtag), + @"framer": framer, + @"message": message, + @"message_length": @(message_length), + @"is_complete": bool2str(is_complete), + })); + }); + return nw_framer_start_result_will_mark_ready; + } + */ + + //we have to simulate nw_connection_state_ready because the connection state will not reflect that while our framer is active + //--> use framer start as "connection active" signal + //first framer start is allowed to directly send data which will be used as tcp early data + if(!wasOpenOnce) + { + wasOpenOnce = YES; + @synchronized(shared_state) { + shared_state.open = YES; + } + //make sure to not do this inside the framer thread to not cause any deadlocks + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [input generateEvent:NSStreamEventOpenCompleted]; + [output generateEvent:NSStreamEventOpenCompleted]; + }); + } + + nw_framer_set_input_handler(framer, ^size_t(nw_framer_t framer) { + [input schedule_read]; + return 0; //why that?? + }); + + shared_state.framer = framer; + return nw_framer_start_result_will_mark_ready; + }); + DDLogInfo(@"%@: Not doing direct TLS: appending framer to protocol stack...", logtag); + nw_protocol_stack_prepend_application_protocol(nw_parameters_copy_default_protocol_stack(parameters), nw_framer_create_options(starttls_framer_definition)); + } + //needed to activate tcp fast open with apple's internal tls framer + nw_parameters_set_fast_open_enabled(parameters, YES); + //use dnssec if configured + if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) + nw_parameters_set_requires_dnssec_validation(parameters, YES); + + //create and configure connection object + nw_endpoint_t endpoint = nw_endpoint_create_host([host cStringUsingEncoding:NSUTF8StringEncoding], [[port stringValue] cStringUsingEncoding:NSUTF8StringEncoding]); + nw_connection_t connection = nw_connection_create(endpoint, parameters); + nw_connection_set_queue(connection, dispatch_queue_create_with_target([NSString stringWithFormat:@"im.monal.networking:%@", logtag].UTF8String, DISPATCH_QUEUE_SERIAL, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0))); + + //configure shared state + shared_state.connection = connection; + shared_state.configure_tls_block = configure_tls_block; + + //configure state change handler proxying state changes to our public stream instances + nw_connection_set_state_changed_handler(connection, ^(nw_connection_state_t state, nw_error_t error) { + @synchronized(shared_state) { + //connection was opened once (e.g. opening=YES) and closed later on (e.g. open=NO) + if(wasOpenOnce && !shared_state.open) + { + DDLogVerbose(@"%@: ignoring call to nw_connection state_changed_handler, connection already closed: %@ --> %du, %@", logtag, self, state, error); + return; + } + } + //always handle errors regardless of current state (cert errors etc.) + if(error != nil) + { + DDLogVerbose(@"%@: %@ got error in state %du and reporting: %@", logtag, self, state, error); + NSError* st_error = (NSError*)CFBridgingRelease(nw_error_copy_cf_error(error)); + @synchronized(shared_state) { + shared_state.error = st_error; + } + [input generateEvent:NSStreamEventErrorOccurred]; + [output generateEvent:NSStreamEventErrorOccurred]; + } + + if(state == nw_connection_state_waiting) + { + //do nothing here, documentation says the connection will be automatically retried "when conditions are favourable" + //which seems to mean: if the network path changed (for example connectivity regained) + //if this happens inside the connection timeout all is ok + //if not, the connection will be cancelled already and everything will be ok, too + DDLogVerbose(@"%@: got nw_connection_state_waiting and ignoring it, see comments in code: %@ (%@)", logtag, self, error); + } + else if(state == nw_connection_state_failed) + { + //errors already reported by generic handling above + DDLogError(@"%@: Connection failed (error already reported): %@", logtag, error); + } + else if(state == nw_connection_state_ready) + { + DDLogInfo(@"%@: Connection established, wasOpenOnce: %@", logtag, bool2str(wasOpenOnce)); + if(!wasOpenOnce) + { + wasOpenOnce = YES; + @synchronized(shared_state) { + shared_state.open = YES; + } + [input generateEvent:NSStreamEventOpenCompleted]; + [output generateEvent:NSStreamEventOpenCompleted]; + } + else + { + //the nw_connection_state_ready state while already wasOpenOnce comes from our framer set to ready + //this informs the upper layer that the connection is in ready state now, but we already treat the framer start + //as connection ready event + + @synchronized(shared_state) { + //tls handshake completed now + shared_state.hasTLS = YES; + + //unlock thread waiting on tls handshake completion (starttls) + [shared_state.tlsHandshakeCompleteCondition lock]; + [shared_state.tlsHandshakeCompleteCondition signal]; + [shared_state.tlsHandshakeCompleteCondition unlock]; + } + + //we still want to inform our stream users that they can write data now and schedule a read operation + [output generateEvent:NSStreamEventHasSpaceAvailable]; + [input schedule_read]; + } + } + else if(state == nw_connection_state_cancelled) + { + //ignore this (we use reference counting) + DDLogVerbose(@"%@: ignoring call to nw_connection state_changed_handler with state nw_connection_state_cancelled: %@ (%@)", logtag, self, error); + } + else if(state == nw_connection_state_invalid) + { + //ignore all other states (preparing, invalid) + DDLogVerbose(@"%@: ignoring call to nw_connection state_changed_handler with state nw_connection_state_invalid: %@ (%@)", logtag, self, error); + } + else if(state == nw_connection_state_preparing) + { + //ignore all other states (preparing, invalid) + DDLogVerbose(@"%@: ignoring call to nw_connection state_changed_handler with state nw_connection_state_preparing: %@ (%@)", logtag, self, error); + } + else + unreachable(); + }); + + *inputStream = (NSInputStream*)input; + *outputStream = (NSOutputStream*)output; +} + +-(void) startTLS +{ + [self.shared_state.tlsHandshakeCompleteCondition lock]; + @synchronized(self.shared_state) { + MLAssert(!self.shared_state.hasTLS, @"We already have TLS on this connection!"); + MLAssert(self.shared_state.framer != nil, @"Trying to start tls handshake without having a running framer!"); + DDLogInfo(@"Starting TLS handshake on framer: %@", self.shared_state.framer); + nw_framer_async(self.shared_state.framer, ^{ + @synchronized(self.shared_state) { + DDLogVerbose(@"Prepending tls to framer: %@", self.shared_state.framer); + nw_framer_t framer = self.shared_state.framer; + self.shared_state.framer = nil; + nw_protocol_options_t tls_options = nw_tls_create_options(); + self.shared_state.configure_tls_block(tls_options); + nw_framer_prepend_application_protocol(framer, tls_options); + nw_framer_pass_through_input(framer); + nw_framer_pass_through_output(framer); + nw_framer_mark_ready(framer); + DDLogVerbose(@"Framer deactivated and TLS prepended now..."); + } + }); + } + [self.shared_state.tlsHandshakeCompleteCondition wait]; + [self.shared_state.tlsHandshakeCompleteCondition unlock]; + DDLogInfo(@"TLS handshake completed: %@...", bool2str(self.shared_state.hasTLS)); +} + +-(BOOL) hasTLS +{ + @synchronized(self.shared_state) { + return self.shared_state.hasTLS; + } +} + +-(instancetype) initWithSharedState:(MLSharedStreamState*) shared +{ + self = [super init]; + self.shared_state = shared; + @synchronized(self.shared_state) { + self.open_called = NO; + self.closed = NO; + self.delegate = self; + } + return self; +} + +-(void) generateEvent:(NSStreamEvent) event +{ + @synchronized(self.shared_state) { + //don't schedule delegate calls if no runloop was specified + if(self.shared_state.runLoop == nil) + return; + //make sure to NOT hold the @synchronized lock when calling the delegate to not introduce deadlocks + BOOL handleEvent = NO; + if(event == NSStreamEventOpenCompleted && self.open_called && self.shared_state.open) + handleEvent = YES; + else if(event == NSStreamEventHasBytesAvailable && self.open_called && self.shared_state.open) + handleEvent = YES; + else if(event == NSStreamEventHasSpaceAvailable && self.open_called && self.shared_state.open) + handleEvent = YES; + else if(event == NSStreamEventErrorOccurred) + handleEvent = YES; + else if(event == NSStreamEventEndEncountered && self.open_called && self.shared_state.open) + handleEvent = YES; + //check if the event should be handled + if(!handleEvent) + DDLogVerbose(@"Ignoring event %ld", (long)event); + else + { + //schedule the delegate calls in the runloop that was registered + CFRunLoopPerformBlock([self.shared_state.runLoop getCFRunLoop], (__bridge CFStringRef)self.shared_state.runLoopMode, ^{ + [self->_delegate stream:self handleEvent:event]; + }); + //trigger wakeup of runloop to execute the block as soon as possible + CFRunLoopWakeUp([self.shared_state.runLoop getCFRunLoop]); + } + } +} + +-(void) open +{ + @synchronized(self.shared_state) { + MLAssert(!self.closed, @"streams can not be reopened!"); + self.open_called = YES; + if(!self.shared_state.opening) + { + DDLogVerbose(@"Calling nw_connection_start()..."); + nw_connection_start(self.shared_state.connection); + } + self.shared_state.opening = YES; + //already opened by stream for other direction? --> directly trigger open event + if(self.shared_state.open) + [self generateEvent:NSStreamEventOpenCompleted]; + } +} + +-(void) close +{ + nw_connection_t connection; + @synchronized(self.shared_state) { + connection = self.shared_state.connection; + } + DDLogVerbose(@"Closing connection via nw_connection_send()..."); + nw_connection_send(connection, NULL, NW_CONNECTION_FINAL_MESSAGE_CONTEXT, YES, ^(nw_error_t _Nullable error) { + if(error) + { + NSError* st_error = (NSError*)CFBridgingRelease(nw_error_copy_cf_error(error)); + @synchronized(self.shared_state) { + self.shared_state.error = st_error; + } + [self generateEvent:NSStreamEventErrorOccurred]; + } + }); + @synchronized(self.shared_state) { + self.closed = YES; + self.shared_state.open = NO; + + //unlock thread waiting on tls handshake + [self.shared_state.tlsHandshakeCompleteCondition lock]; + [self.shared_state.tlsHandshakeCompleteCondition signal]; + [self.shared_state.tlsHandshakeCompleteCondition unlock]; + } +} + +-(void) setDelegate:(id) delegate +{ + _delegate = delegate; + if(_delegate == nil) + _delegate = self; +} + +-(void) scheduleInRunLoop:(NSRunLoop*) loop forMode:(NSRunLoopMode) mode +{ + @synchronized(self.shared_state) { + self.shared_state.runLoop = loop; + self.shared_state.runLoopMode = mode; + } +} + +-(void) removeFromRunLoop:(NSRunLoop*) loop forMode:(NSRunLoopMode) mode +{ + @synchronized(self.shared_state) { + self.shared_state.runLoop = nil; + self.shared_state.runLoopMode = mode; + } +} + +-(id) propertyForKey:(NSStreamPropertyKey) key +{ + return [super propertyForKey:key]; +} + +-(BOOL) setProperty:(id) property forKey:(NSStreamPropertyKey) key +{ + return [super setProperty:property forKey:key]; +} + +-(NSStreamStatus) streamStatus +{ + @synchronized(self.shared_state) { + if(self.shared_state.error) + return NSStreamStatusError; + else if(!self.open_called && self.closed) + return NSStreamStatusNotOpen; + else if(self.open_called && self.shared_state.open) + return NSStreamStatusOpen; + else if(self.open_called) + return NSStreamStatusOpening; + else if(self.closed) + return NSStreamStatusClosed; + } + unreachable(); + return 0; +} + +-(NSError*) streamError +{ + NSError* error = nil; + @synchronized(self.shared_state) { + error = self.shared_state.error; + } + return error; +} + +//list supported channel-binding types (highest security first!) +-(NSArray*) supportedChannelBindingTypes +{ + //we made sure we only use PFS based ciphers for which tls-exporter can safely be used even with TLS1.2 + //(see https://mitls.org/pages/attacks/3SHAKE) + return @[@"tls-exporter", @"tls-server-end-point"]; + + /* + //BUT: other implementations simply don't support tls-exporter on non-tls1.3 connections --> do the same for compatibility + if(self.isTLS13) + return @[@"tls-exporter", @"tls-server-end-point"]; + return @[@"tls-server-end-point"]; + */ +} + +-(NSData* _Nullable) channelBindingDataForType:(NSString* _Nullable) type +{ + //don't log a warning in this special case + if(type == nil) + return nil; + + if([@"tls-exporter" isEqualToString:type]) + return [self channelBindingData_TLSExporter]; + else if([@"tls-server-end-point" isEqualToString:type]) + return [self channelBindingData_TLSServerEndPoint]; + else if([kServerDoesNotFollowXep0440Error isEqualToString:type]) + return [kServerDoesNotFollowXep0440Error dataUsingEncoding:NSUTF8StringEncoding]; + + unreachable(@"Trying to use unknown channel-binding type!", (@{@"type":type})); +} + +-(BOOL) isTLS13 +{ + @synchronized(self.shared_state) { + MLAssert([self streamStatus] >= NSStreamStatusOpen && [self streamStatus] < NSStreamStatusClosed, @"Stream must be open to call this method!", (@{@"streamStatus": @([self streamStatus])})); + MLAssert(self.shared_state.hasTLS, @"Stream must have TLS negotiated to call this method!"); + nw_protocol_metadata_t p_metadata = nw_connection_copy_protocol_metadata(self.shared_state.connection, nw_protocol_copy_tls_definition()); + MLAssert(nw_protocol_metadata_is_tls(p_metadata), @"Protocol metadata is not TLS!"); + sec_protocol_metadata_t s_metadata = nw_tls_copy_sec_protocol_metadata(p_metadata); + return sec_protocol_metadata_get_negotiated_tls_protocol_version(s_metadata) == tls_protocol_version_TLSv13; + } +} + +-(NSData*) channelBindingData_TLSExporter +{ + @synchronized(self.shared_state) { + MLAssert([self streamStatus] >= NSStreamStatusOpen && [self streamStatus] < NSStreamStatusClosed, @"Stream must be open to call this method!", (@{@"streamStatus": @([self streamStatus])})); + MLAssert(self.shared_state.hasTLS, @"Stream must have TLS negotiated to call this method!"); + nw_protocol_metadata_t p_metadata = nw_connection_copy_protocol_metadata(self.shared_state.connection, nw_protocol_copy_tls_definition()); + MLAssert(nw_protocol_metadata_is_tls(p_metadata), @"Protocol metadata is not TLS!"); + sec_protocol_metadata_t s_metadata = nw_tls_copy_sec_protocol_metadata(p_metadata); + //see https://www.rfc-editor.org/rfc/rfc9266.html + return (NSData*)sec_protocol_metadata_create_secret(s_metadata, 24, "EXPORTER-Channel-Binding", 32); + } +} + +-(NSData*) channelBindingData_TLSServerEndPoint +{ + @synchronized(self.shared_state) { + MLAssert([self streamStatus] >= NSStreamStatusOpen && [self streamStatus] < NSStreamStatusClosed, @"Stream must be open to call this method!", (@{@"streamStatus": @([self streamStatus])})); + MLAssert(self.shared_state.hasTLS, @"Stream must have TLS negotiated to call this method!"); + nw_protocol_metadata_t p_metadata = nw_connection_copy_protocol_metadata(self.shared_state.connection, nw_protocol_copy_tls_definition()); + MLAssert(nw_protocol_metadata_is_tls(p_metadata), @"Protocol metadata is not TLS!"); + sec_protocol_metadata_t s_metadata = nw_tls_copy_sec_protocol_metadata(p_metadata); + __block NSData* cert = nil; + sec_protocol_metadata_access_peer_certificate_chain(s_metadata, ^(sec_certificate_t certificate) { + if(cert == nil) + cert = (__bridge_transfer NSData*)SecCertificateCopyData(sec_certificate_copy_ref(certificate)); + }); + MLCrypto* crypto = [MLCrypto new]; + NSString* signatureAlgo = [crypto getSignatureAlgoOfCert:cert]; + DDLogDebug(@"Signature algo OID: %@", signatureAlgo); + //OIDs taken from https://www.rfc-editor.org/rfc/rfc3279#section-2.2.3 and "Updated by" RFCs + if([@"1.2.840.113549.2.5" isEqualToString:signatureAlgo]) //md5WithRSAEncryption + return [HelperTools sha256:cert]; //use sha256 as per RFC 5929 + else if([@"1.3.14.3.2.26" isEqualToString:signatureAlgo]) //sha1WithRSAEncryption + return [HelperTools sha256:cert]; //use sha256 as per RFC 5929 + else if([@"1.2.840.113549.1.1.11" isEqualToString:signatureAlgo]) //sha256WithRSAEncryption + return [HelperTools sha256:cert]; + else if([@"1.2.840.113549.1.1.12" isEqualToString:signatureAlgo]) //sha384WithRSAEncryption (not supported, return sha256, will fail cb) + { + DDLogError(@"Using sha256 for unsupported OID %@ (sha384WithRSAEncryption)", signatureAlgo); + return [HelperTools sha256:cert]; + } + else if([@"1.2.840.113549.1.1.13" isEqualToString:signatureAlgo]) //sha512WithRSAEncryption + return [HelperTools sha512:cert]; + else if([@"1.2.840.113549.1.1.14" isEqualToString:signatureAlgo]) //sha224WithRSAEncryption (not supported, return sha256, will fail cb) + { + DDLogError(@"Using sha256 for unsupported OID %@ (sha224WithRSAEncryption)", signatureAlgo); + return [HelperTools sha256:cert]; + } + else if([@"1.2.840.10045.4.1" isEqualToString:signatureAlgo]) //ecdsa-with-SHA1 + return [HelperTools sha256:cert]; + else if([@"1.2.840.10045.4.3.1" isEqualToString:signatureAlgo]) //ecdsa-with-SHA224 (not supported, return sha256, will fail cb) + { + DDLogError(@"Using sha256 for unsupported OID %@ (ecdsa-with-SHA224)", signatureAlgo); + return [HelperTools sha256:cert]; + } + else if([@"1.2.840.10045.4.3.2" isEqualToString:signatureAlgo]) //ecdsa-with-SHA256 + return [HelperTools sha256:cert]; + else if([@"1.2.840.10045.4.3.3" isEqualToString:signatureAlgo]) //ecdsa-with-SHA384 (not supported, return sha256, will fail cb) + { + DDLogError(@"Using sha256 for unsupported OID %@ (ecdsa-with-SHA384)", signatureAlgo); + return [HelperTools sha256:cert]; + } + else if([@"1.2.840.10045.4.3.4" isEqualToString:signatureAlgo]) //ecdsa-with-SHA512 + return [HelperTools sha256:cert]; + else if([@"1.3.6.1.5.5.7.6.32" isEqualToString:signatureAlgo]) //id-ecdsa-with-shake128 (not supported, return sha256, will fail cb) + { + DDLogError(@"Using sha256 for unsupported OID %@ (id-ecdsa-with-shake128)", signatureAlgo); + return [HelperTools sha256:cert]; + } + else if([@"1.3.6.1.5.5.7.6.33" isEqualToString:signatureAlgo]) //id-ecdsa-with-shake256 (not supported, return sha256, will fail cb) + { + DDLogError(@"Using sha256 for unsupported OID %@ (id-ecdsa-with-shake256)", signatureAlgo); + return [HelperTools sha256:cert]; + } + else //all other algos use sha256 (that most probably will fail cb) + { + DDLogError(@"Using sha256 for unknown/unsupported OID: %@", signatureAlgo); + return [HelperTools sha256:cert]; + } + } +} + +-(void) stream:(NSStream*) stream handleEvent:(NSStreamEvent) event +{ + //ignore event in this dummy delegate + DDLogVerbose(@"ignoring event in dummy delegate: %@ --> %ld", stream, (long)event); +} + +@end diff --git a/Monal/Classes/MLStreamRedirect.h b/Monal/Classes/MLStreamRedirect.h new file mode 100644 index 0000000..cfdcbbb --- /dev/null +++ b/Monal/Classes/MLStreamRedirect.h @@ -0,0 +1,23 @@ +// +// MLStreamRedirect.h +// monalxmpp +// +// Created by Thilo Molitor on 18.08.23. +// Copyright © 2023 monal-im.org. All rights reserved. +// + +#import +#include + +#ifndef MLStreamRedirect_h +#define MLStreamRedirect_h + +@interface MLStreamRedirect : NSObject +-(instancetype) initWithStream:(FILE*) stream; +-(void) flush; +-(void) flushWithTimeout:(double) timeout; +-(void) flushAndClose; +-(void) flushAndCloseWithTimeout:(NSTimeInterval) timeout; +@end + +#endif /* MLStreamRedirect_h */ diff --git a/Monal/Classes/MLStreamRedirect.m b/Monal/Classes/MLStreamRedirect.m new file mode 100644 index 0000000..b075622 --- /dev/null +++ b/Monal/Classes/MLStreamRedirect.m @@ -0,0 +1,192 @@ +// +// MLStreamRedirect.m +// monalxmpp +// +// Created by Thilo Molitor on 18.08.23. +// Copyright © 2023 monal-im.org. All rights reserved. +// + +#import "MLConstants.h" +#import "HelperTools.h" +#import "MLStreamRedirect.h" + +@interface MLStreamRedirect () { + FILE* _stream; + BOOL _valid; + NSPipe* _pipe; + NSCondition* _threadCondition; + int _origStreamFileno; + NSThread* _readingThread; + NSString* _eofMarkerUUID; + BOOL _flushCompleted; +} +@end + +@implementation MLStreamRedirect + +//see https://stackoverflow.com/a/16395493 and https://stackoverflow.com/q/53978091 +//and https://medium.com/@thesaadismail/eavesdropping-on-swifts-print-statements-57f0215efb42 +-(instancetype) initWithStream:(FILE*) stream +{ + self = [super init]; + self->_stream = stream; + self->_eofMarkerUUID = [[NSUUID UUID] UUIDString]; + self->_valid = NO; //will be set to yes if everything worked out + + _pipe = [NSPipe pipe]; + if(_pipe == nil) + [NSException raise:@"NSError" format:@"Failed to create pipe for outfd %d!", fileno(stream)]; + + //reassign stream + DDLogDebug(@"Redirecting outfd %d...", fileno(stream)); + _origStreamFileno = dup(fileno(stream)); + dup2([[_pipe fileHandleForWriting] fileDescriptor], fileno(stream)); + setvbuf(stream, nil, _IONBF, 0); + + _threadCondition = [NSCondition new]; + self->_flushCompleted = NO; + + //make sure we run as fast as possible using a dedicated thread with very high priority to finish stderr logging during a crash + _readingThread = [[NSThread alloc] initWithTarget:self selector:@selector(readingThreadMain) object:nil]; + //_readingThread.threadPriority = 1.0; + _readingThread.qualityOfService = NSQualityOfServiceUserInteractive; + [_readingThread setName:[NSString stringWithFormat:@"StreamRedirectorThreadForFD:%d", fileno(_stream)]]; + [_readingThread start]; + self->_valid = YES; + + return self; +} + +-(void) readingThreadMain +{ + //read other end of pipe and copy data into cocoa lumberjack + DDLogDebug(@"Starting outfd %d reading loop...", fileno(self->_stream)); + while(![[NSThread currentThread] isCancelled]) + { + NSData* data = [self->_pipe fileHandleForReading].availableData; + if([data length] == 0) + { + DDLogWarn(@"EOF reached"); + break; + } + + NSString* logstr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSArray* parts = [logstr componentsSeparatedByString:self->_eofMarkerUUID]; + for(NSString* logpart in parts) + { + //don't separate by \n, this will often stuff normal logmessages in between our lines even if they belong together + //for(NSString* line in [logpart componentsSeparatedByString:@"\n"]) + NSString* line = logpart; + { + //ignore empty parts (e.g. eof marker or \n at end of string) + if([line length] == 0) + continue; + if(self->_stream == stdout) + DDLogStdout(@"%@", line); + else if(self->_stream == stderr) + DDLogStderr(@"%@", line); + else + DDLogVerbose(@"UNKNOWN_STREAM: %@", line); + } + } + //a flush token was detected, signal we received it + if([parts count] > 1) + [self signalFlushCompleted]; + } + self->_valid = NO; + DDLogDebug(@"Stopped outfd %d reading loop...", fileno(self->_stream)); + [self signalFlushCompleted]; + + //recover original file descriptor for good measure (leaving stdout and stderr in closed state can exhibit unexpected behavour) + dup2(self->_origStreamFileno, fileno(self->_stream)); +} + +-(void) flush +{ + return [self flushWithWaitBlock:^{ + [self waitForFlushCompleted]; + }]; +} + +-(void) flushWithTimeout:(NSTimeInterval) timeout +{ + return [self flushWithWaitBlock:^{ + [self waitForFlushCompletedWithTimeout:timeout]; + }]; +} + +-(void) flushAndClose +{ + return [self flushAndCloseWithWaitBlock:^{ + [self waitForFlushCompleted]; + }]; +} + +-(void) flushAndCloseWithTimeout:(NSTimeInterval) timeout +{ + return [self flushAndCloseWithWaitBlock:^{ + [self waitForFlushCompletedWithTimeout:timeout]; + }]; +} + +-(void) flushWithWaitBlock:(monal_void_block_t) waitBlock +{ + if(!self->_valid) + [NSException raise:@"NSError" format:@"Stream redirector for outfd %d already invalidated!", fileno(self->_stream)]; + + //send our own eof marker through the pipe, this allows us to keep the pipe open + fprintf(self->_stream, "%s", [self->_eofMarkerUUID UTF8String]); + fflush(self->_stream); + + //wait for this flush to complete and flush our DDLog afterwards to make sure everything reached the log sinks + DDLogVerbose(@"Waiting for flush of fd %d to complete...", fileno(self->_stream)); + waitBlock(); + DDLogVerbose(@"Flush on fd %d completed...", fileno(self->_stream)); + [DDLog flushLog]; +} + +-(void) flushAndCloseWithWaitBlock:(monal_void_block_t) waitBlock +{ + if(!self->_valid) + [NSException raise:@"NSError" format:@"Stream redirector for outfd %d already invalidated!", fileno(self->_stream)]; + + //send our own eof marker through the pipe to counter buffering issues (especially on stdout) + [self flush]; + + //according to apple's developer docs closing the pipe's fileHandleForWriting will send an eof signal to the reader (zero length NSData) + NSError* error = nil; + [[_pipe fileHandleForWriting] closeAndReturnError:&error]; + if(error != nil) + [NSException raise:@"NSError" format:@"Error closing outfd %d pipe: %@", fileno(self->_stream), error]; + fflush(self->_stream); //needed for stdio because of buffering + [_readingThread cancel]; + + //wait for this eof signal and flush our DDLog afterwards to make sure everything reached the log sinks + waitBlock(); + [DDLog flushLog]; +} + +-(void) signalFlushCompleted +{ + [self->_threadCondition lock]; + self->_flushCompleted = YES; + [self->_threadCondition signal]; + [self->_threadCondition unlock]; +} + +-(void) waitForFlushCompleted +{ + [self->_threadCondition lock]; + [self->_threadCondition wait]; + [self->_threadCondition unlock]; +} + +-(void) waitForFlushCompletedWithTimeout:(NSTimeInterval) timeout +{ + [self->_threadCondition lock]; + if(![self->_threadCondition waitUntilDate:[NSDate dateWithTimeIntervalSinceNow:timeout]]) + DDLogWarn(@"Timeout waiting for UUID EOF marker at outfd %d!", fileno(self->_stream)); + [self->_threadCondition unlock]; +} + +@end diff --git a/Monal/Classes/MLSwitchCell.h b/Monal/Classes/MLSwitchCell.h new file mode 100644 index 0000000..5d3e54c --- /dev/null +++ b/Monal/Classes/MLSwitchCell.h @@ -0,0 +1,60 @@ +// +// MLAccountCell.h +// Monal +// +// Created by Anurodh Pokharel on 2/8/15. +// Copyright (c) 2015 Monal.im. All rights reserved. +// + +#import + +typedef float (^sliderUpdate)(UILabel* labelToUpdate, float sliderValue); + +@interface MLSwitchCell : UITableViewCell + +/** + Label to the left + */ +@property (nonatomic, weak) IBOutlet UILabel* cellLabel; + +/** + UIswitch + */ +@property (nonatomic, weak) IBOutlet UISwitch* toggleSwitch; + +/** + UIswitch + */ +@property (nonatomic, weak) IBOutlet UISlider* slider; + +/** + Textinput field + */ +@property (nonatomic, weak) IBOutlet UITextField* textInputField; + +/** +Label to the right +*/ +@property (weak, nonatomic) IBOutlet UILabel* labelRight; + +-(void) clear; + +-(void) initTapCell:(NSString*) leftLabel; + +// UILabel +-(void) initCell:(NSString*) leftLabel withLabel:(NSString*) rightLabel; + +// UITextField +-(void) initCell:(NSString*) leftLabel withTextField:(NSString*) rightText andPlaceholder:(NSString*) placeholder andTag:(uint16_t) tag; +-(void) initCell:(NSString*) leftLabel withTextField:(NSString*) rightText secureEntry:(BOOL) secureEntry andPlaceholder:(NSString*) placeholder andTag:(uint16_t) tag; +-(void) initCell:(NSString*) leftLabel withTextFieldDefaultsKey:(NSString*) key andPlaceholder:(NSString*) placeholder; + +// UISwitch +-(void) initCell:(NSString*) leftLabel withToggle:(BOOL) toggleValue andTag:(uint16_t) tag; +-(void) initCell:(NSString*) leftLabel withToggleDefaultsKey:(NSString*) key; + +// UISlider +-(void) initCell:(NSString*) leftLabel withSliderDefaultsKey:(NSString*) key minValue:(float) minValue maxValue:(float) maxValue; +-(void) initCell:(NSString*) leftLabel withSliderDefaultsKey:(NSString*) key minValue:(float) minValue maxValue:(float) maxValue withLoadFunc:(sliderUpdate) sliderLoad withUpdateFunc:(sliderUpdate) sliderUpdate; + +@end diff --git a/Monal/Classes/MLSwitchCell.m b/Monal/Classes/MLSwitchCell.m new file mode 100644 index 0000000..ddad39c --- /dev/null +++ b/Monal/Classes/MLSwitchCell.m @@ -0,0 +1,171 @@ +// +// MLAccountCell.m +// Monal +// +// Created by Anurodh Pokharel on 2/8/15. +// Copyright (c) 2015 Monal.im. All rights reserved. +// + +#import "MLSwitchCell.h" +#import "HelperTools.h" + +@interface MLSwitchCell () + +@property (nonatomic, strong) NSString* defaultsKey; +@property (nonatomic, strong) sliderUpdate sliderFilter; + +@end + +@implementation MLSwitchCell + +-(void) clear +{ + self.defaultsKey = nil; + self.sliderFilter = nil; + + self.cellLabel.text = nil; + + self.labelRight.text = nil; + self.labelRight.hidden = YES; + + self.textInputField.text = nil; + self.textInputField.hidden = YES; + + self.toggleSwitch.hidden = YES; + + self.slider.hidden = YES; + + self.imageView.image = nil; + self.textLabel.text = nil; + self.detailTextLabel.text = nil; + self.accessoryView = nil; + + self.accessoryType = UITableViewCellAccessoryNone; +} + +-(void) initTapCell:(NSString*) leftLabel +{ + [self clear]; + + self.cellLabel.text = leftLabel; + self.accessoryType = UITableViewCellAccessoryDisclosureIndicator; +} + +-(void) initCell:(NSString*) leftLabel withLabel:(NSString*) rightLabel +{ + [self clear]; + + self.cellLabel.text = leftLabel; + self.labelRight.text = rightLabel; + self.labelRight.hidden = NO; +} + +-(void) initCell:(NSString*) leftLabel withTextField:(NSString*) rightText andPlaceholder:(NSString*) placeholder andTag:(uint16_t) tag +{ + [self initCell:leftLabel withTextField:rightText secureEntry:NO andPlaceholder:placeholder andTag:tag]; +} + +-(void) initCell:(NSString*) leftLabel withTextField:(NSString*) rightText secureEntry:(BOOL) secureEntry andPlaceholder:(NSString*) placeholder andTag:(uint16_t) tag +{ + [self clear]; + + self.cellLabel.text = leftLabel; + self.textInputField.text = rightText; + self.textInputField.placeholder = placeholder; + self.textInputField.tag = tag; + self.textInputField.secureTextEntry = secureEntry; + self.textInputField.hidden = NO; +} + +-(void) initCell:(NSString*) leftLabel withTextFieldDefaultsKey:(NSString*) key andPlaceholder:(NSString*) placeholder +{ + [self initCell:leftLabel withTextField:[[HelperTools defaultsDB] stringForKey:key] andPlaceholder:placeholder andTag:0]; + self.defaultsKey = key; +} + +-(void) initCell:(NSString*) leftLabel withToggle:(BOOL) toggleValue andTag:(uint16_t) tag +{ + [self clear]; + + self.cellLabel.text = leftLabel; + self.toggleSwitch.on = toggleValue; + self.toggleSwitch.tag = tag; + self.toggleSwitch.hidden = NO; +} + +-(void) initCell:(NSString*) leftLabel withToggleDefaultsKey:(NSString*) key +{ + [self initCell:leftLabel withToggle:[[HelperTools defaultsDB] boolForKey:key] andTag:0]; + [self.toggleSwitch addTarget:self action:@selector(switchChange) forControlEvents:UIControlEventValueChanged]; + self.defaultsKey = key; +} + +-(void) initCell:(NSString*) leftLabel withSliderDefaultsKey:(NSString*) key minValue:(float) minValue maxValue:(float) maxValue +{ + [self initCell:leftLabel withSliderDefaultsKey:key minValue:minValue maxValue:maxValue withLoadFunc:nil withUpdateFunc:nil]; +} + +-(void) initCell:(NSString*) leftLabel withSliderDefaultsKey:(NSString*) key minValue:(float) minValue maxValue:(float) maxValue withLoadFunc:(sliderUpdate) sliderLoad withUpdateFunc:(sliderUpdate) sliderUpdate +{ + [self clear]; + + self.cellLabel.text = leftLabel; + + self.slider.minimumValue = minValue; + self.slider.maximumValue = maxValue; + + if(sliderLoad) + self.slider.value = sliderLoad(self.cellLabel, [[HelperTools defaultsDB] floatForKey:key]); + else + self.slider.value = [[HelperTools defaultsDB] floatForKey:key]; + + [self.slider addTarget:self action:@selector(sliderChange) forControlEvents:UIControlEventValueChanged]; + _defaultsKey = key; + self.sliderFilter = sliderUpdate; + self.slider.hidden = NO; +} + +#pragma mark uiswitch delegate + +-(void) switchChange +{ + if(self.defaultsKey == nil) + return; + // save new switch state to defaultsDB + [[HelperTools defaultsDB] setBool:_toggleSwitch.on forKey:self.defaultsKey]; +} + +#pragma mark uilabel delegate + +-(void) sliderChange +{ + if(self.defaultsKey == nil) + return; + float filteredValue; + + if(self.sliderFilter == nil) + filteredValue = self.slider.value; + else + filteredValue = self.sliderFilter(self.cellLabel, self.slider.value); + + // save new slider state to defaultsDB + [[HelperTools defaultsDB] setFloat:filteredValue forKey:self.defaultsKey]; +} + +#pragma mark uitextfield delegate +-(void) textFieldDidBeginEditing:(UITextField*) textField +{ +} + +-(BOOL) textFieldShouldReturn:(UITextField*) textField +{ + if(self.defaultsKey == nil) + return YES; + // save new value to defaultsDB + [[HelperTools defaultsDB] setObject:_textInputField.text forKey: self.defaultsKey]; + [textField resignFirstResponder]; + return YES; +} + + +@end diff --git a/Monal/Classes/MLSwitchCell.xib b/Monal/Classes/MLSwitchCell.xib new file mode 100644 index 0000000..5459787 --- /dev/null +++ b/Monal/Classes/MLSwitchCell.xib @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Classes/MLTextInputCell.h b/Monal/Classes/MLTextInputCell.h new file mode 100644 index 0000000..1385d60 --- /dev/null +++ b/Monal/Classes/MLTextInputCell.h @@ -0,0 +1,21 @@ +// +// MLTextInputCell.h +// Monal +// +// Created by Anurodh Pokharel on 4/10/15. +// Copyright (c) 2015 Monal.im. All rights reserved. +// + +#import + +@interface MLTextInputCell : UITableViewCell + +-(void) initTextCell:(NSString*) text andPlaceholder:(NSString*) placeholder andDelegate:(id) delegate; +-(void) initMailCell:(NSString*) text andPlaceholder:(NSString*) placeholder andDelegate:(id) delegate; +-(void) initPasswordCell:(NSString*) text andPlaceholder:(NSString*) placeholder andDelegate:(id) delegate; + +-(void) disableEditMode; + +-(NSString*) getText; + +@end diff --git a/Monal/Classes/MLTextInputCell.m b/Monal/Classes/MLTextInputCell.m new file mode 100644 index 0000000..bff47af --- /dev/null +++ b/Monal/Classes/MLTextInputCell.m @@ -0,0 +1,74 @@ +// +// MLTextInputCell.m +// Monal +// +// Created by Anurodh Pokharel on 4/10/15. +// Copyright (c) 2015 Monal.im. All rights reserved. +// + +#import "MLTextInputCell.h" + +@interface MLTextInputCell() +@property (nonatomic, weak) IBOutlet UITextField* textInput; +@end + +@implementation MLTextInputCell + +- (void)awakeFromNib { + [super awakeFromNib]; + // Initialization code + self.textInput.clearButtonMode=UITextFieldViewModeUnlessEditing; +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated { + [super setSelected:selected animated:animated]; + + // Configure the view for the selected state +} + +-(void) setupCellWithText:(NSString*) text andPlaceholder:(NSString*) placeholder andDelegate:(id) delegate +{ + self.textInput.text = text; + self.textInput.secureTextEntry = NO; + self.textInput.placeholder = placeholder; + self.textInput.enabled = YES; + // enable autocorrection + self.textInput.autocorrectionType = UITextAutocorrectionTypeYes; + if(delegate != nil) + { + self.textInput.delegate = delegate; + } +} + +-(void) initTextCell:(NSString*) text andPlaceholder:(NSString*) placeholder andDelegate:(id) delegate +{ + [self setupCellWithText:text andPlaceholder:placeholder andDelegate:delegate]; + [self.textInput setKeyboardType:UIKeyboardTypeDefault]; +} + +-(void) initMailCell:(NSString*) text andPlaceholder:(NSString*) placeholder andDelegate:(id) delegate +{ + [self setupCellWithText:text andPlaceholder:placeholder andDelegate:delegate]; + [self.textInput setKeyboardType:UIKeyboardTypeEmailAddress]; + // disable autocorrection + self.textInput.autocorrectionType = UITextAutocorrectionTypeNo; +} + +-(void) initPasswordCell:(NSString*) text andPlaceholder:(NSString*) placeholder andDelegate:(id) delegate +{ + [self setupCellWithText:text andPlaceholder:placeholder andDelegate:delegate]; + self.textInput.secureTextEntry = YES; + [self.textInput setKeyboardType:UIKeyboardTypeDefault]; +} + +-(void) disableEditMode +{ + self.textInput.enabled = NO; +} + +-(NSString*) getText +{ + return [self.textInput.text copy]; +} + +@end diff --git a/Monal/Classes/MLTextInputCell.xib b/Monal/Classes/MLTextInputCell.xib new file mode 100644 index 0000000..858ef3a --- /dev/null +++ b/Monal/Classes/MLTextInputCell.xib @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Classes/MLUDPLogger.h b/Monal/Classes/MLUDPLogger.h new file mode 100644 index 0000000..c889e14 --- /dev/null +++ b/Monal/Classes/MLUDPLogger.h @@ -0,0 +1,20 @@ +// +// MLUDPLogger.h +// monalxmpp +// +// Created by Thilo Molitor on 17.08.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLUDPLogger : DDAbstractLogger + ++(void) flushWithTimeout:(double) timeout; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLUDPLogger.m b/Monal/Classes/MLUDPLogger.m new file mode 100644 index 0000000..81d1977 --- /dev/null +++ b/Monal/Classes/MLUDPLogger.m @@ -0,0 +1,294 @@ +// +// MLUDPLogger.m +// monalxmpp +// +// Created by Thilo Molitor on 17.08.20. +// Copyright © 2020 Monal.im. All rights reserved. +// Based on this gist: https://gist.github.com/ratulSharker/3b6bce0debe77fd96344e14566b23e06 +// + +#import +#import +#import +#import +#import +#import "MLUDPLogger.h" +#import "HelperTools.h" +#import "AESGcm.h" +#import "MLXMPPManager.h" +#import "MLContact.h" +#import "xmpp.h" + +static NSData* _key; +static volatile MLUDPLogger* _self; + +@interface MLUDPLogger () +{ + volatile nw_connection_t _connection; + volatile dispatch_queue_t _send_queue; + volatile NSCondition* _send_condition; + volatile nw_error_t _last_error; + volatile u_int64_t _counter; +} ++(void) logError:(NSString*) format, ... NS_FORMAT_FUNCTION(1, 2); +@end + + +@implementation MLUDPLogger + ++(void) initialize +{ + //hash raw key string with sha256 to get the correct 256 bit length needed for AES-256 + //WARNING: THIS DOES NOT ENHANCE ENTROPY!! PLEASE MAKE SURE TO USE A KEY WITH PROPER ENTROPY!! + NSData* rawKey = [[[HelperTools defaultsDB] stringForKey:@"udpLoggerKey"] dataUsingEncoding:NSUTF8StringEncoding]; + NSMutableData* key = [NSMutableData dataWithLength:CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(rawKey.bytes, (unsigned int)rawKey.length, key.mutableBytes); + _key = [key copy]; +} + ++(void) flushWithTimeout:(double) timeout +{ + if(_self != nil) + { + NSCondition* condition = [NSCondition new]; + //this timeout will trigger if the flush could not be finished in time (leeway of 10ms) + //use dispatch_source_set_timer() directly instead of createTimer() because we don't want to log anything in here + dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)); + dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC)), DISPATCH_TIME_FOREVER, (uint64_t)(0.010 * NSEC_PER_SEC)); + dispatch_source_set_event_handler(timer, ^{ + [[self class] logError:@"flush timer triggered!"]; + dispatch_source_cancel(timer); + [condition lock]; + [condition signal]; + [condition unlock]; + }); + dispatch_resume(timer); + //this block will be executed if all prior blocks managed to send out their messages (e.g. queue is flushed) + dispatch_async(_self->_send_queue, ^{ + [[self class] logError:@"flush succeeded in time"]; + dispatch_source_cancel(timer); //stop timer + [condition lock]; + [condition signal]; + [condition unlock]; + }); + //wait for either timeout or flush to trigger + [condition lock]; + [condition wait]; + [condition unlock]; + } +} + +-(void) dealloc +{ + _self = nil; +} + +-(void) didAddLogger +{ + _self = self; + _send_condition = [NSCondition new]; + _send_queue = dispatch_queue_create("MLUDPLoggerSendQueue", dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0)); +} + +-(void) willRemoveLogger +{ + _self = nil; +} + ++(void) logError:(NSString*) format, ... NS_FORMAT_FUNCTION(1, 2) +{ +#ifdef IS_ALPHA + va_list args; + va_start(args, format); + NSString* message = [[NSString alloc] initWithFormat:format arguments:args]; + va_end(args); + + NSLog(@"MLUDPLogger: %@", message); + + /* + //log error in 250ms + dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)); + dispatch_source_set_timer(timer, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.250*NSEC_PER_SEC)), + DISPATCH_TIME_FOREVER, + (uint64_t)0); + dispatch_source_set_event_handler(timer, ^{ + DDLogError(@"%@", message); + }); + */ +#endif +} + +//code taken from here: https://stackoverflow.com/a/11389847/3528174 +-(NSData*) gzipDeflate:(NSData*) data +{ + if([data length] == 0) + return data; + + z_stream strm; + + strm.zalloc = Z_NULL; + strm.zfree = Z_NULL; + strm.opaque = Z_NULL; + strm.total_out = 0; + strm.next_in = (Bytef*)[data bytes]; + strm.avail_in = (unsigned int)[data length]; + + // Compresssion Levels: + // Z_NO_COMPRESSION + // Z_BEST_SPEED + // Z_BEST_COMPRESSION + // Z_DEFAULT_COMPRESSION + if(deflateInit2(&strm, Z_BEST_COMPRESSION, Z_DEFLATED, (15+16), 8, Z_DEFAULT_STRATEGY) != Z_OK) + { + [[self class] logError:@"gzipDeflate error"]; + return nil; + } + + NSMutableData* compressed = [NSMutableData dataWithLength:16384]; // 16K chunks for expansion + do { + if(strm.total_out >= [compressed length]) + [compressed increaseLengthBy:16384]; + strm.next_out = [compressed mutableBytes] + strm.total_out; + strm.avail_out = (unsigned int)([compressed length] - strm.total_out); + deflate(&strm, Z_FINISH); + } while(strm.avail_out == 0); + deflateEnd(&strm); + + [compressed setLength:strm.total_out]; + return compressed; +} + +-(void) disconnect +{ + if(_connection != NULL) + nw_connection_force_cancel(_connection); + _connection = NULL; + [_send_condition lock]; + [_send_condition signal]; + [_send_condition unlock]; +} + +-(void) createConnectionIfNeeded +{ + if(_connection == NULL) + { + __block NSCondition* condition = [NSCondition new]; + + nw_endpoint_t endpoint = nw_endpoint_create_host([[[HelperTools defaultsDB] stringForKey:@"udpLoggerHostname"] cStringUsingEncoding:NSUTF8StringEncoding], [[[HelperTools defaultsDB] stringForKey:@"udpLoggerPort"] cStringUsingEncoding:NSUTF8StringEncoding]); + nw_parameters_t parameters = nw_parameters_create_secure_udp(NW_PARAMETERS_DISABLE_PROTOCOL, NW_PARAMETERS_DEFAULT_CONFIGURATION); + + _connection = nw_connection_create(endpoint, parameters); + nw_connection_set_queue(_connection, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0)); + nw_connection_set_state_changed_handler(_connection, ^(nw_connection_state_t state, nw_error_t error) { + if(state == nw_connection_state_ready) + { + [condition lock]; + [condition signal]; + [condition unlock]; + } + //udp connections should be "established" in way less than 100ms, so unlock this (dispatch) queue after 100ms + //the connection blocking longer mostly happens if the device has no connectivity (state waiting) + else if(state == nw_connection_state_waiting || state == nw_connection_state_preparing) + { + usleep(100000); + [condition lock]; + [condition signal]; + [condition unlock]; + } + //retry in all error cases + else if(state == nw_connection_state_failed || state == nw_connection_state_cancelled || state == nw_connection_state_invalid) + { + self->_last_error = error; + [[self class] logError:@"connect error: %@", error]; + self->_connection = NULL; + [condition lock]; + [condition signal]; + [condition unlock]; + [self->_send_condition lock]; + [self->_send_condition signal]; + [self->_send_condition unlock]; + } + }); + [condition lock]; + nw_connection_start(_connection); + [condition wait]; + [condition unlock]; + + //try again if we did not succeed + if(_connection == NULL) + { + [[self class] logError:@"retrying connection start..."]; + [self createConnectionIfNeeded]; + } + } +} + +-(void) logMessage:(DDLogMessage*) logMessage +{ + static uint64_t counter = 0; + + //early return if deactivated + if(![[HelperTools defaultsDB] boolForKey: @"udpLoggerEnabled"]) + return; + + NSError* error = nil; + NSData* rawData = [HelperTools convertLogmessageToJsonData:logMessage counter:&counter andError:&error]; + if(error != nil || rawData == nil) + { + [[self class] logError:@"json encode error: %@", error]; + return; + } + + //compress data to account for udp size limits + rawData = [self gzipDeflate:rawData]; + + //encrypt rawData using the "derived" key (see warning above!) + MLEncryptedPayload* payload = [AESGcm encrypt:rawData withKey:_key]; + NSMutableData* data = [NSMutableData dataWithData:payload.iv]; + [data appendData:payload.authTag]; + [data appendData:payload.body]; + + if(data.length > 65000) + [[self class] logError:@"not sending message, too big: %lu", (unsigned long)data.length]; + else + dispatch_async(_send_queue, ^{ + [self sendData:data withOriginalMessage:logMessage->_message]; + }); +} + +-(void) sendData:(NSData*) data withOriginalMessage:(NSString*) msg +{ + [self createConnectionIfNeeded]; + + //the call to dispatch_get_main_queue() is a dummy because we are using DISPATCH_DATA_DESTRUCTOR_DEFAULT which is performed inline + [_send_condition lock]; + nw_connection_send(_connection, dispatch_data_create(data.bytes, data.length, dispatch_get_main_queue(), DISPATCH_DATA_DESTRUCTOR_DEFAULT), NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, true, ^(nw_error_t _Nullable error) { + self->_last_error = error; + if(error != NULL) + { + //NSError* st_error = (NSError*)CFBridgingRelease(nw_error_copy_cf_error(error)); + [[self class] logError:@"send error: %@\n%@", error, msg]; + + } + //[[self class] logError:@"unlocking send condition (%@)...", [NSNumber numberWithUnsignedLongLong:self->_counter]]; + [self->_send_condition lock]; + [self->_send_condition signal]; + [self->_send_condition unlock]; + }); + //block this queue until our udp message was sent or an error occured + [_send_condition wait]; + [_send_condition unlock]; + if(_last_error != NULL) + { + //don't retry if message was too long + if([@"Message too long" isEqualToString:[NSString stringWithFormat:@"%@", _last_error]]) + return; + //retry + //[self disconnect]; + [[self class] logError:@"retrying sendData with error: %@", _last_error]; + [self sendData:data withOriginalMessage:msg]; + } +} + +@end diff --git a/Monal/Classes/MLUploadQueueCell.h b/Monal/Classes/MLUploadQueueCell.h new file mode 100644 index 0000000..0bdd42c --- /dev/null +++ b/Monal/Classes/MLUploadQueueCell.h @@ -0,0 +1,31 @@ +// +// MLUploadQueueDocumentCell.h +// Monal +// +// Created by Jan on 13.04.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol MLUploadQueueCellDelegate +-(void) notifyUploadQueueRemoval:(NSUInteger)index; +@end + +@interface MLUploadQueueCell : UICollectionViewCell + +@property (nonatomic) NSUInteger index; +@property (weak, nonatomic) id uploadQueueDelegate; +@property (weak, nonatomic) IBOutlet UIButton* closeButton; + +-(IBAction) closeButtonAction; +-(void) initCellWithPreviewImage:(UIImage* _Nullable) previewImage filename:(NSString* _Nullable) filename index:(NSUInteger) idx; + +@property (weak, nonatomic) IBOutlet UILabel* fileName; +@property (weak, nonatomic) IBOutlet UIImageView *previewImage; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLUploadQueueCell.m b/Monal/Classes/MLUploadQueueCell.m new file mode 100644 index 0000000..1e15aa7 --- /dev/null +++ b/Monal/Classes/MLUploadQueueCell.m @@ -0,0 +1,31 @@ +// +// MLUploadQueueCell.m +// Monal +// +// Created by Jan on 13.04.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import "MLUploadQueueCell.h" + +@implementation MLUploadQueueCell + +-(IBAction) closeButtonAction +{ + [self.uploadQueueDelegate notifyUploadQueueRemoval:self.index]; +} + +-(void) initCellWithPreviewImage:(UIImage* _Nullable) previewImage filename:(NSString* _Nullable) filename index:(NSUInteger) idx +{ + if(previewImage == nil) + previewImage = [UIImage systemImageNamed:@"doc"]; + self.previewImage.image = previewImage; + self.fileName.text = filename; + self.index = idx; + if(filename == nil) + self.fileName.hidden = YES; + else + self.fileName.hidden = NO; +} + +@end diff --git a/Monal/Classes/MLVoIPProcessor.h b/Monal/Classes/MLVoIPProcessor.h new file mode 100644 index 0000000..89ba6b5 --- /dev/null +++ b/Monal/Classes/MLVoIPProcessor.h @@ -0,0 +1,32 @@ +// +// MLVoIPProcessor.h +// Monal +// +// Created by admin on 03.07.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +#ifndef MLVoIPProcessor_h +#define MLVoIPProcessor_h + +NS_ASSUME_NONNULL_BEGIN + +@class CXCallController; +@class CXProvider; +@class MLCall; +@class MLContact; +typedef NS_ENUM(NSUInteger, MLCallType); + +@interface MLVoIPProcessor : NSObject +-(MLCall*) initiateCallWithType:(MLCallType) callType toContact:(MLContact*) contact; + +@property (nonatomic, readonly) NSUInteger pendingCallsCount; +-(NSDictionary*) getActiveCalls; +-(MLCall* _Nullable) getActiveCallWithContact:(MLContact*) contact; + +-(void) voipRegistration; +@end + +NS_ASSUME_NONNULL_END + +#endif /* MLVoIPProcessor_h */ diff --git a/Monal/Classes/MLVoIPProcessor.m b/Monal/Classes/MLVoIPProcessor.m new file mode 100644 index 0000000..2c721ba --- /dev/null +++ b/Monal/Classes/MLVoIPProcessor.m @@ -0,0 +1,841 @@ +// +// MLVoIPProcessor.m +// Monal +// +// Created by admin on 03.07.22. +// Copyright © 2022 Monal.im. 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 "ActiveChatsViewController.h" +#import "MLNotificationQueue.h" +#import "secrets.h" + +@import PushKit; +@import CallKit; +@import WebRTC; + +static NSMutableDictionary* _pendingCalls; + +@interface MLVoIPProcessor() +{ +} +@property (nonatomic, strong) CXCallController* _Nullable callController; +@property (nonatomic, strong) CXProvider* _Nullable cxProvider; +@end + +@interface MLCall() +@property (nonatomic, strong) NSString* jmiid; +@property (nonatomic) MLCallDirection direction; + +@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 tieBreak; +@property (nonatomic, strong) AVAudioSession* _Nullable audioSession; +@property (nonatomic, assign) MLCallFinishReason finishReason; + +@property (nonatomic, readonly) xmpp* account; +@property (nonatomic, readonly) MLVoIPProcessor* voipProcessor; + +-(instancetype) initWithUUID:(NSUUID*) uuid jmiid:(NSString*) jmiid contact:(MLContact*) contact callType:(MLCallType) callType andDirection:(MLCallDirection) direction; +-(void) migrateTo:(MLCall*) otherCall; +-(NSString*) short; +-(void) reportRinging; +-(void) handleEndCallActionWithReason:(MLCallFinishReason) reason; +-(void) sendJmiReject; +-(void) sendJmiPropose; +-(void) sendJmiRinging; +@end + + +@implementation MLVoIPProcessor + ++(void) initialize +{ + _pendingCalls = [NSMutableDictionary new]; +} + +-(id) init +{ + self = [super init]; + + CXProviderConfiguration* config = [CXProviderConfiguration new]; + config.maximumCallGroups = 1; + config.maximumCallsPerCallGroup = 1; + config.supportedHandleTypes = [NSSet setWithObject:@(CXHandleTypeGeneric)]; + config.supportsVideo = YES; + config.includesCallsInRecents = YES; + //see https://stackoverflow.com/a/45823730/3528174 +#ifndef IS_QUICKSY + config.iconTemplateImageData = UIImagePNGRepresentation([UIImage imageNamed:@"CallKitLogo"]); +#else + config.iconTemplateImageData = UIImagePNGRepresentation([UIImage imageNamed:@"QuicksyCallKitLogo"]); +#endif + self.cxProvider = [[CXProvider alloc] initWithConfiguration:config]; + [self.cxProvider setDelegate:self queue:dispatch_get_main_queue()]; + self.callController = [[CXCallController alloc] initWithQueue:dispatch_get_main_queue()]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIncomingVoipCall:) name:kMonalIncomingVoipCall object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIncomingJMIStanza:) name:kMonalIncomingJMIStanza object:nil]; + + return self; +} + +-(void) deinit +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(MLCall* _Nullable) getCallForUUID:(NSUUID*) uuid +{ + @synchronized(_pendingCalls) { + return _pendingCalls[uuid]; + } +} + +-(void) addCall:(MLCall*) call +{ + DDLogInfo(@"Adding call to list: %@", call); + @synchronized(_pendingCalls) { + _pendingCalls[call.uuid] = call; + } + [[MLNotificationQueue currentQueue] postNotificationName:kMonalCallAdded object:call userInfo:@{@"uuid": call.uuid}]; +} + +-(void) removeCall:(MLCall*) call +{ + DDLogInfo(@"Removing call from list: %@", call); + @synchronized(_pendingCalls) { + [_pendingCalls removeObjectForKey:call.uuid]; + } + [[MLNotificationQueue currentQueue] postNotificationName:kMonalCallRemoved object:call userInfo:@{@"uuid": call.uuid}]; +} + +-(NSUInteger) pendingCallsCount +{ + return _pendingCalls.count; +} + +-(NSDictionary*) getActiveCalls +{ + @synchronized(_pendingCalls) { + return [_pendingCalls copy]; + } +} + +-(MLCall* _Nullable) getActiveCallWithContact:(MLContact*) contact +{ + @synchronized(_pendingCalls) { + for(NSUUID* uuid in _pendingCalls) + { + MLCall* call = [self getCallForUUID:uuid]; + if([call.contact isEqualToContact:contact]) + return call; + } + } + return nil; +} + +-(MLCall* _Nullable) getCallForJmiid:(NSString*) jmiid +{ + @synchronized(_pendingCalls) { + for(NSUUID* uuid in _pendingCalls) + { + MLCall* call = [self getCallForUUID:uuid]; + if([call.jmiid isEqualToString:jmiid]) + return call; + } + } + return nil; +} + +-(MLCall*) initiateCallWithType:(MLCallType) callType toContact:(MLContact*) contact +{ + xmpp* account = contact.account; + MLAssert(account != nil, @"account is nil in initiateCallWithType:ToContact:!", (@{@"contact": contact})); + + NSUUID* uuid = [NSUUID UUID]; + MLCall* call = [[MLCall alloc] initWithUUID:uuid jmiid:uuid.UUIDString contact:contact callType:callType andDirection:MLCallDirectionOutgoing]; + DDLogInfo(@"Initiating %@ call to %@: %@", (callType==MLCallTypeAudio ? @"audio" : @"video"), contact, call); + [self addCall:call]; + + CXHandle* handle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:contact.contactJid]; + CXStartCallAction* startCallAction = [[CXStartCallAction alloc] initWithCallUUID:call.uuid handle:handle]; + startCallAction.contactIdentifier = call.contact.contactDisplayName; + startCallAction.video = call.callType == MLCallTypeVideo; + CXTransaction* transaction = [[CXTransaction alloc] initWithAction:startCallAction]; + [self.callController requestTransaction:transaction completion:^(NSError* error) { + if(error != nil) + { + DDLogError(@"Error requesting start call transaction: %@", error); + [self removeCall:call]; + } + else + DDLogInfo(@"Successfully created outgoing call transaction for CallKit.."); + }]; + return call; +} + +-(void) voipRegistration +{ + PKPushRegistry* voipRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()]; + voipRegistry.delegate = self; + voipRegistry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP]; +} + +// Handle updated APNS tokens +-(void) pushRegistry:(PKPushRegistry*) registry didUpdatePushCredentials:(PKPushCredentials*) credentials forType:(NSString*) type +{ + NSString* token = [HelperTools stringFromToken:credentials.token]; + DDLogDebug(@"Ignoring APNS voip token string for type %@: %@", type, token); +} + +-(void) pushRegistry:(PKPushRegistry*) registry didInvalidatePushTokenForType:(NSString*) type +{ + DDLogDebug(@"APNS voip didInvalidatePushTokenForType:%@ called and ignored...", type); +} + +//called if jmi propose was received by appex +-(void) pushRegistry:(PKPushRegistry*) registry didReceiveIncomingPushWithPayload:(PKPushPayload*) payload forType:(PKPushType) type withCompletionHandler:(void (^)(void)) completion +{ + DDLogInfo(@"Received voip push with payload: %@", payload); + NSDictionary* userInfo = [HelperTools unserializeData:[HelperTools dataWithBase64EncodedString:payload.dictionaryPayload[@"base64Payload"]]]; + [self processIncomingCall:userInfo withCompletion:completion]; +} + +//called if jmi propose was received by mainapp +-(void) handleIncomingVoipCall:(NSNotification*) notification +{ + DDLogInfo(@"Received JMI propose directly in mainapp..."); + //handle tie breaking: check for already running call + //(this is only needed if we are in the mainapp because of an already "running" call we now have to tie break) + XMPPMessage* messageNode = notification.userInfo[@"messageNode"]; + NSNumber* accountID = notification.userInfo[@"accountID"]; + MLContact* contact = [MLContact createContactFromJid:messageNode.fromUser andAccountID:accountID]; + MLCall* existingCall = [self getActiveCallWithContact:contact]; + if(existingCall == nil || existingCall.state == MLCallStateFinished) + return [self processIncomingCall:notification.userInfo withCompletion:nil]; + + MLCall* newCall = [self createCallWithJmiPropose:messageNode onAccountID:accountID]; + if(newCall == nil) + return; + + //handle tie breaking: both parties call each other "simultaneously" + DDLogDebug(@"Found existing call, trying to break the tie: %@", existingCall); + if(existingCall.state < MLCallStateConnecting) //e.g. MLCallStateDiscovering or MLCallStateRinging + { + //determine call sort order + NSData* existingID = [[existingCall.jmiPropose findFirst:@"{urn:xmpp:jingle-message:0}propose@id"] dataUsingEncoding:NSUTF8StringEncoding]; + NSData* newID = [[newCall.jmiPropose findFirst:@"{urn:xmpp:jingle-message:0}propose@id"] dataUsingEncoding:NSUTF8StringEncoding]; + int result = [HelperTools compareIOcted:existingID with:newID]; + if(result == 0) + result = [HelperTools compareIOcted:[existingCall.contact.contactJid dataUsingEncoding:NSUTF8StringEncoding] with:[newCall.contact.contactJid dataUsingEncoding:NSUTF8StringEncoding]]; + + DDLogInfo(@"Tie-breaking new incoming call: existingID=%@, newID=%@, result=%d, existingCall=%@, newCall=%@", existingID, newID, result, existingCall, newCall); + if(result <= 0) //keep existingID, reject new call having a higher ID + { + //reject new call and do nothing with the existingCall + newCall.tieBreak = YES; + [newCall handleEndCallActionWithReason:MLCallFinishReasonDeclined]; + } + else //use newID, hang up existing call having a higher ID + { + //hang up existing call (retract it) and process new incoming call + existingCall.tieBreak = YES; + [existingCall end]; + [self processIncomingCall:notification.userInfo withCompletion:nil]; + } + return; + } + //handle tie breaking: one party migrates the call to other device + else if(existingCall.state < MLCallStateFinished) //call already running + { + if(newCall.callType == existingCall.callType) + { + DDLogInfo(@"Migrating from new call to existing call: %@", existingCall); + [existingCall migrateTo:newCall]; + + //drop new call after migration to make sure it does not interfere with our existing call + DDLogInfo(@"Dropping newCall '%@' in favor of migrated existingCall '%@' ...", [newCall short], [existingCall short]); + newCall = nil; + } + else + { + existingCall.tieBreak = YES; //will be ignored if call was connected, but it doesn't hurt either + [existingCall end]; + [self processIncomingCall:notification.userInfo withCompletion:nil]; + } + return; + } + unreachable(); +} + +-(void) processIncomingCall:(NSDictionary* _Nonnull) userInfo withCompletion:(void (^ _Nullable)(void)) completion +{ + //TODO: handle jmi propose coming from other devices on our account (see TODO in MLMessageProcessor.m) + XMPPMessage* messageNode = userInfo[@"messageNode"]; + NSNumber* accountID = userInfo[@"accountID"]; + + MLCall* call = [self createCallWithJmiPropose:messageNode onAccountID:accountID]; + if(call == nil) + { + //ios will stop delivering voip notifications if an incoming pushkit notification doesn't trigger a visible ringing + //indication within 5 seconds (one single time is permitted, the second time we get punished indefinitely) + MLAssert(completion == nil, @"Got nil call but wakeup was via pushkit, this is dangerous!!", userInfo); + + //call pushkit completion if given + if(completion != nil) + completion(); + return; + } + DDLogInfo(@"Now processing new incoming call: %@", call); + + //add call to pending calls list + [self addCall:call]; + + [self.cxProvider reportNewIncomingCallWithUUID:call.uuid update:[self constructUpdateForCall:call] completion:^(NSError *error) { + //add our completion handler to handler queue to initiate xmpp connections + //this must be done in main thread because the app delegate is only allowed in main thread + dispatch_async(dispatch_get_main_queue(), ^{ + MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate]; + [appDelegate incomingWakeupWithCompletionHandler:^(UIBackgroundFetchResult result __unused) { + DDLogWarn(@"VoIP push wakeup handler timed out"); + }]; + }); + + if(error != nil) + { + DDLogError(@"Call disallowed by system: %@", error); + [call sendJmiReject]; + //remove this call from pending calls + [self removeCall:call]; + } + else + { + DDLogDebug(@"Call reported successfully using CallKit, initializing xmpp and WebRTC now..."); + + //initialize webrtc class (ask for external service credentials, gather ice servers etc.) for call as soon as the callkit ui is shown + //this will be done once the app delegate started to connect our xmpp accounts above + //do this in an extra thread to not block this callback thread (could be main thread or otherwise restricted by apple) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + DDLogDebug(@"Sending jmi ringing message..."); + [call sendJmiRinging]; + + //wait for our account to connect before initializing webrtc using XEP-0215 iq stanzas + //if the user proceeds the call before we are bound, the outgoing proceed message stanza will be queued and sent once we are bound + //outgoing iq messages are not queued in all cases (e.g. non-smacks reconnect), hence this waiting loop + while(call.account.accountState < kStateBound) + [NSThread sleepForTimeInterval:0.250]; + + DDLogDebug(@"Account is connected, now really initialize WebRTC..."); + [self initWebRTCForPendingCall:call]; + }); + } + }]; + + //call pushkit completion if given + if(completion != nil) + completion(); +} + +-(void) initWebRTCForPendingCall:(MLCall*) call +{ + DDLogInfo(@"Initializing WebRTC for: %@", call); + NSMutableArray* iceServers = [NSMutableArray new]; + if([call.account.connectionProperties.discoveredStunTurnServers count] > 0) + { + for(NSDictionary* service in call.account.connectionProperties.discoveredStunTurnServers) + [call.account queryExternalServiceCredentialsFor:service completion:^(id data) { + //this will not include any credentials if we got an error answer for our credentials query (data will be an empty dict) + //--> just use the server without credentials then + NSMutableDictionary* serviceWithCredentials = [NSMutableDictionary dictionaryWithDictionary:service]; + [serviceWithCredentials addEntriesFromDictionary:(NSDictionary*)data]; + DDLogDebug(@"Got new external service credentials: %@", serviceWithCredentials); + //transport is only defined for turn/turns, but not for stun/stuns from M110 onwards + NSString* transport = @""; + if([@[@"turn", @"turns"] containsObject:serviceWithCredentials[@"type"]]) + { + if(serviceWithCredentials[@"username"] == nil || serviceWithCredentials[@"password"] == nil) + { + DDLogWarn(@"Invalid turn/turns credentials: missing username or password, ignoring!"); + return; + } + if(serviceWithCredentials[@"transport"] != nil) + transport = [NSString stringWithFormat:@"?transport=%@", serviceWithCredentials[@"transport"]]; + } + [iceServers addObject:[[RTCIceServer alloc] + initWithURLStrings:@[[NSString stringWithFormat:@"%@:%@:%@%@", + serviceWithCredentials[@"type"], + [HelperTools isIP:serviceWithCredentials[@"host"]] ? [NSString stringWithFormat:@"[%@]", serviceWithCredentials[@"host"]] : serviceWithCredentials[@"host"], + serviceWithCredentials[@"port"], + transport + ]] + username:serviceWithCredentials[@"username"] + credential:serviceWithCredentials[@"password"] + tlsCertPolicy:RTCTlsCertPolicyInsecureNoCheck + ]]; + + //proceed only if all ice servers have been processed + if([iceServers count] == [call.account.connectionProperties.discoveredStunTurnServers count]) + { + DDLogInfo(@"Done processing ICE servers, trying to connect WebRTC session..."); + [self createWebRTCClientForCall:call usingICEServers:iceServers]; + } + }]; + } + else if([[HelperTools defaultsDB] boolForKey: @"webrtcUseFallbackTurn"]) + { + DDLogInfo(@"No ICE servers detected, trying to connect WebRTC session using our own STUN servers as fallback..."); + //use own stun server as fallback + [iceServers addObject:[[RTCIceServer alloc] initWithURLStrings:[HelperTools getFailoverStunServers]]]; + + // request turn credentials + NSMutableURLRequest* urlRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"/api/v1/challenge/new" relativeToURL:[HelperTools getFailoverTurnApiServer]]]; + if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) + urlRequest.requiresDNSSECValidation = YES; + [urlRequest setTimeoutInterval:3.0]; + NSURLSession* challengeSession = [HelperTools createEphemeralURLSession]; + [[challengeSession dataTaskWithRequest:urlRequest completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + if(error != nil || [(NSHTTPURLResponse*)response statusCode] != 200) + { + DDLogWarn(@"Could not retrieve turn challenge, only using stun: %@", error); + [self createWebRTCClientForCall:call usingICEServers:iceServers]; + return; + } + // parse challenge + NSError* challengeJsonErr; + NSDictionary* challenge = [NSJSONSerialization JSONObjectWithData:data options:0 error:&challengeJsonErr]; + if(challengeJsonErr != nil && [challenge objectForKey:@"challenge"] != nil) + { + DDLogWarn(@"Could not parse turn challenge, only using stun: %@", challengeJsonErr); + [self createWebRTCClientForCall:call usingICEServers:iceServers]; + return; + } + + unsigned long validUntil = [challenge[@"validUntil"] unsignedLongValue]; + NSMutableData* challengeHmacInput = [NSMutableData dataWithBytes:&validUntil length:sizeof(validUntil)]; + [challengeHmacInput appendData:[challenge[@"challenge"] dataUsingEncoding:NSUTF8StringEncoding]]; + + // create challenge response + NSDictionary* challengeResponseDict = @{ + @"challenge": challenge, + @"appId": TURN_API_SECRET_ID, + @"challengeResponse": [HelperTools encodeBase64WithData:[HelperTools sha512HmacForKey:[TURN_API_SECRET dataUsingEncoding:NSUTF8StringEncoding] andData:challengeHmacInput]], + }; + NSError* challengeRespJsonErr; + NSData* challengeResp = [NSJSONSerialization dataWithJSONObject:challengeResponseDict options:kNilOptions error:&challengeRespJsonErr]; + if(challengeRespJsonErr != nil) + { + DDLogWarn(@"Could not create json challenge reponse, only using stun: %@", challengeRespJsonErr); + [self createWebRTCClientForCall:call usingICEServers:iceServers]; + return; + } + NSMutableURLRequest* responseRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"/api/v1/challenge/validate" relativeToURL:[HelperTools getFailoverTurnApiServer]]]; + if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) + responseRequest.requiresDNSSECValidation = YES; + + [responseRequest setHTTPMethod:@"POST"]; + [responseRequest setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + [responseRequest setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [responseRequest setValue:[NSString stringWithFormat:@"%lu", (unsigned long)[challengeResp length]] forHTTPHeaderField:@"Content-Length"]; + [responseRequest setTimeoutInterval:3.0]; + [responseRequest setHTTPBody:challengeResp]; + + NSURLSession* responseSession = [HelperTools createEphemeralURLSession]; + [[responseSession dataTaskWithRequest:responseRequest completionHandler:^(NSData* turnCredentialsData, NSURLResponse* response, NSError* error) { + if(error != nil || [(NSHTTPURLResponse*)response statusCode] != 200) + { + DDLogWarn(@"Could not retrieve turn credentials, only using stun: %@", error); + [self createWebRTCClientForCall:call usingICEServers:iceServers]; + return; + } + NSError* turnCredentialsErr; + NSDictionary* turnCredentials = [NSJSONSerialization JSONObjectWithData:turnCredentialsData options:0 error:&turnCredentialsErr]; + if(turnCredentials == nil || turnCredentials[@"username"] == nil || turnCredentials[@"password"] == nil || turnCredentials[@"uris"] == nil) + { + DDLogWarn(@"Could not parse turn credentials, only using stun: %@", turnCredentialsErr); + [self createWebRTCClientForCall:call usingICEServers:iceServers]; + return; + } + [iceServers addObject:[[RTCIceServer alloc] initWithURLStrings:[turnCredentials objectForKey:@"uris"] username:[turnCredentials objectForKey:@"username"] credential:[turnCredentials objectForKey:@"password"]]]; + + [self createWebRTCClientForCall:call usingICEServers:iceServers]; + }] resume]; + }] resume]; + } + //continue without any stun/turn servers if only p2p but no stun/turn servers could be found on local xmpp server + //AND no fallback to monal servers was configured + else + { + [self createWebRTCClientForCall:call usingICEServers:@[]]; + } +} + +-(void) createWebRTCClientForCall:(MLCall*) call usingICEServers:(NSArray*) iceServers +{ + BOOL forceRelay = ![[HelperTools defaultsDB] boolForKey:@"webrtcAllowP2P"]; + DDLogInfo(@"Initializing webrtc with forceRelay=%@ using ice servers: %@", bool2str(forceRelay), iceServers); + MLAssert(call.webRTCClient == nil, @"Call does already have a webrtc client object!", (@{@"old_client": call.webRTCClient})); + WebRTCClient* webRTCClient = [[WebRTCClient alloc] initWithIceServers:iceServers audioOnly:call.callType==MLCallTypeAudio forceRelay:forceRelay]; + call.webRTCClient = webRTCClient; + webRTCClient.delegate = call; +} + +-(void) handleIncomingJMIStanza:(NSNotification*) notification +{ + XMPPMessage* messageNode = notification.userInfo[@"messageNode"]; + MLAssert(messageNode != nil, @"messageNode is nil in handleIncomingJMIStanza!", notification.userInfo); + xmpp* account = notification.object; + MLAssert(account != nil, @"account is nil in handleIncomingJMIStanza!", notification.userInfo); + + return [self handleIncomingJMIStanza:messageNode onAccount:account]; +} + +-(void) handleIncomingJMIStanza:(XMPPMessage*) messageNode onAccount:(xmpp*) account +{ + NSString* jmiid = [messageNode findFirst:@"{urn:xmpp:jingle-message:0}*@id"]; + MLAssert(jmiid != nil, @"call jmiid invalid!", (@{@"*@id": nilWrapper([messageNode findFirst:@"{urn:xmpp:jingle-message:0}*@id"])})); + MLCall* call = [self getCallForJmiid:jmiid]; + if(call == nil) + { + DDLogWarn(@"Ignoring unexpected JMI stanza for unknown call: %@", messageNode); + //TODO: log action in history db (see TODO in MLMessageProcessor.m) + return; + } + + DDLogInfo(@"Got new incoming JMI stanza for call: %@", call); + if(call.direction == MLCallDirectionIncoming && [messageNode check:@"{urn:xmpp:jingle-message:0}proceed"]) + { + if(![messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring bogus jmi proceed of incoming call NOT coming from other device on our account..."); + return; + } + if(call.jmiProceed != nil) + { + DDLogWarn(@"Someone tried to proceed an already proceeded incoming call, ignoring this jmi proceed!"); + return; + } + + [call handleEndCallActionWithReason:MLCallFinishReasonAnsweredElsewhere]; + } + else if(call.direction == MLCallDirectionOutgoing && [messageNode check:@"{urn:xmpp:jingle-message:0}proceed"]) + { + if(call.jmiProceed != nil) + { + DDLogWarn(@"Someone tried to proceed an already proceeded outgoing call, ignoring this jmi proceed!"); + return; + } + + //order matters here! + call.fullRemoteJid = messageNode.from; + call.jmiProceed = messageNode; + } + else if(call.direction == MLCallDirectionIncoming && [messageNode check:@"{urn:xmpp:jingle-message:0}ringing"]) + { + if(![messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring bogus jmi ringing of incoming call NOT coming from other device on our account..."); + return; + } + + DDLogWarn(@"Ignoring jmi ringing of incoming call coming from other device on our account..."); + } + else if(call.direction == MLCallDirectionOutgoing && [messageNode check:@"{urn:xmpp:jingle-message:0}ringing"]) + { + if([messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring bogus jmi ringing of outgoing call coming from other device on our account..."); + return; + } + + if(call.jmiPropose == nil) + { + DDLogWarn(@"Other device did try to report state ringing for a not yet proposed call, ignoring this jmi ringing!"); + return; + } + + [call reportRinging]; + } + else if(call.direction == MLCallDirectionIncoming && [messageNode check:@"{urn:xmpp:jingle-message:0}reject"]) + { + if(![messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring bogus jmi reject of incoming call NOT coming from other device on our account..."); + return; + } + if(call.jmiProceed != nil) + { + DDLogWarn(@"Other device did try to reject already proceeded call, ignoring this jmi reject!"); + return; + } + + DDLogVerbose(@"Marking incoming call as rejected..."); + [call handleEndCallActionWithReason:MLCallFinishReasonRejected]; + } + else if(call.direction == MLCallDirectionOutgoing && [messageNode check:@"{urn:xmpp:jingle-message:0}reject"]) + { + if([messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring bogus jmi reject of outgoing call coming from other device on our account..."); + return; + } + if(call.jmiProceed != nil) + { + DDLogWarn(@"Remote did try to reject already proceeded call, ignoring this jmi reject!"); + return; + } + + DDLogVerbose(@"Marking outgoing call as rejected..."); + [call handleEndCallActionWithReason:MLCallFinishReasonRejected]; + } + else if(call.direction == MLCallDirectionIncoming && [messageNode check:@"{urn:xmpp:jingle-message:0}retract"]) + { + if([messageNode.fromUser isEqualToString:account.connectionProperties.identity.jid]) + { + DDLogWarn(@"Ignoring bogus jmi retract of incoming call coming from other device on our account..."); + return; + } + if(call.jmiProceed != nil) + { + DDLogWarn(@"Remote did try to retract already proceeded call"); + return; + } + + [call handleEndCallActionWithReason:MLCallFinishReasonUnanswered]; + } + //this should never happen: one cannot retract a call initiated on one device using another device + else if(call.direction == MLCallDirectionOutgoing && [messageNode check:@"{urn:xmpp:jingle-message:0}retract"]) + { + DDLogError(@"This jmi retract should never happen: one cannot retract a call initiated on one device using another device!"); + return; + } + else if([messageNode check:@"{urn:xmpp:jingle-message:0}finish"]) + { + NSString* reason = [messageNode findFirst:@"{urn:xmpp:jingle-message:0}finish/{urn:xmpp:jingle:1}reason/*$"]; + if(call.jmiProceed != nil) + { + DDLogInfo(@"Remote finished call with reason: %@", reason); + [call end]; //use "end" because this was a successful call + } + else + { + DDLogWarn(@"Remote did try to finish an not yet established call"); + if([@"connectivity-error" isEqualToString:reason]) + [call handleEndCallActionWithReason:MLCallFinishReasonConnectivityError]; + else + [call handleEndCallActionWithReason:MLCallFinishReasonAnsweredElsewhere]; + } + } + else + DDLogWarn(@"NOT handling JMI stanza, wrong jmi-type/direction combination: %@", messageNode); +} + +#pragma mark - CXProvider delegate + +-(void) providerDidReset:(CXProvider*) provider +{ + DDLogDebug(@"CXProvider: providerDidReset with provider=%@", provider); +} + +-(void) providerDidBegin:(CXProvider*) provider +{ + DDLogDebug(@"CXProvider: providerDidBegin with provider=%@", provider); +} + +-(void) provider:(CXProvider*) provider performStartCallAction:(CXStartCallAction*) action +{ + MLCall* call = [self getCallForUUID:action.callUUID]; + DDLogDebug(@"CXProvider: performStartCallAction with provider=%@, CXStartCallAction=%@, pendingCallsInfo: %@", provider, action, call); + if(call == nil) + { + DDLogWarn(@"Pending call not present anymore: %@", (@{ + @"provider": provider, + @"action": action, + @"uuid": action.callUUID, + })); + [action fail]; + return; + } + + //update call info to include the right info + [self.cxProvider reportCallWithUUID:call.uuid updated:[self constructUpdateForCall:call]]; + + //propose call to contact (e.g. let it ring) + [call sendJmiPropose]; + + //initialize webrtc class (ask for external service credentials, gather ice servers etc.) for call as soon as the JMI propose was sent + [self initWebRTCForPendingCall:call]; + + [action fulfill]; +} + +-(void) provider:(CXProvider*) provider performAnswerCallAction:(CXAnswerCallAction*) action +{ + MLCall* call = [self getCallForUUID:action.callUUID]; + DDLogDebug(@"CXProvider: performAnswerCallAction with provider=%@, CXAnswerCallAction=%@, pendingCallsInfo: %@", provider, action, call); + if(call == nil) + { + DDLogWarn(@"Pending call not present anymore: %@", (@{ + @"provider": provider, + @"action": action, + @"uuid": action.callUUID, + })); + [action fail]; + return; + } + call.providerAnswerAction = action; + + MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate]; + [appDelegate.activeChats presentCall:call]; +} + +-(void) provider:(CXProvider*) provider performEndCallAction:(CXEndCallAction*) action +{ + MLCall* call = [self getCallForUUID:action.callUUID]; + DDLogDebug(@"CXProvider: performEndCallAction with provider=%@, CXEndCallAction=%@, pendingCallsInfo: %@", provider, action, call); + if(call == nil) + { + DDLogWarn(@"Pending call not present anymore: %@", (@{ + @"provider": provider, + @"action": action, + @"uuid": action.callUUID, + })); + [action fail]; + return; + } + + //handle call termination + if(call.direction == MLCallDirectionIncoming) + { + if(call.isConnected) + [call handleEndCallActionWithReason:MLCallFinishReasonNormal]; + else if(call.jmiProceed != nil) + [call handleEndCallActionWithReason:MLCallFinishReasonConnectivityError]; + else + [call handleEndCallActionWithReason:MLCallFinishReasonDeclined]; //send reject + } + else + { + if(call.isConnected) + [call handleEndCallActionWithReason:MLCallFinishReasonNormal]; + else if(call.jmiProceed != nil) + [call handleEndCallActionWithReason:MLCallFinishReasonConnectivityError]; + else + [call handleEndCallActionWithReason:MLCallFinishReasonRetracted]; //send retract + } + + [action fulfill]; +} + +-(void) provider:(CXProvider*) provider performSetMutedCallAction:(CXSetMutedCallAction*) action +{ + MLCall* call = [self getCallForUUID:action.callUUID]; + DDLogDebug(@"CXProvider: performSetMutedCallAction with provider=%@, CXSetMutedCallAction=%@, pendingCallsInfo: %@", provider, action, call); + if(call == nil) + { + DDLogWarn(@"Pending call not present anymore: %@", (@{ + @"provider": provider, + @"action": action, + @"uuid": action.callUUID, + })); + [action fail]; + return; + } + + call.muted = action.muted; + [action fulfill]; +} + +-(void) provider:(CXProvider*) provider didActivateAudioSession:(AVAudioSession*) audioSession +{ + DDLogDebug(@"CXProvider: didActivateAudioSession with provider=%@, audioSession=%@", provider, audioSession); + @synchronized(_pendingCalls) { + for(NSUUID* uuid in _pendingCalls) + { + MLCall* call = [self getCallForUUID:uuid]; + call.audioSession = audioSession; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate]; + DDLogVerbose(@"Setting audio state to MLAudioStateCall..."); + appDelegate.audioState = MLAudioStateCall; + }); + } +} + +-(void) provider:(CXProvider*) provider didDeactivateAudioSession:(AVAudioSession*) audioSession +{ + DDLogDebug(@"CXProvider: didDeactivateAudioSession with provider=%@, audioSession=%@", provider, audioSession); + @synchronized(_pendingCalls) { + for(NSUUID* uuid in _pendingCalls) + { + MLCall* call = [self getCallForUUID:uuid]; + call.audioSession = nil; + } + + //switch back to default audio settings destroyed by callkit + dispatch_async(dispatch_get_main_queue(), ^{ + [HelperTools configureDefaultAudioSession]; + MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate]; + DDLogVerbose(@"Setting audio state to MLAudioStateNormal..."); + appDelegate.audioState = MLAudioStateNormal; + }); + } +} + +-(CXCallUpdate*) constructUpdateForCall:(MLCall*) call +{ + CXCallUpdate* update = [CXCallUpdate new]; + update.remoteHandle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:call.contact.contactJid]; + update.localizedCallerName = call.contact.contactDisplayName; + update.supportsDTMF = NO; + update.hasVideo = call.callType == MLCallTypeVideo; + update.supportsHolding = NO; + update.supportsGrouping = NO; + update.supportsUngrouping = NO; + return update; +} + +-(MLCall* _Nullable) createCallWithJmiPropose:(XMPPMessage*) messageNode onAccountID:(NSNumber*) accountID +{ + //if the jmi id is a uuid, just use it, otherwise infer a uuid from the given jmi id + NSUUID* uuid = [messageNode findFirst:@"{urn:xmpp:jingle-message:0}propose@id|uuidcast"]; + NSString* jmiid = [messageNode findFirst:@"{urn:xmpp:jingle-message:0}propose@id"]; + MLAssert(uuid != nil, @"call uuid invalid!", (@{@"propose@id": nilWrapper(jmiid)})); + + //check if we are in a loop (both accounts participating in this call on the same monal instance) + //--> ignore this incoming call if that is true + if([self getCallForJmiid:jmiid] != nil) + { + DDLogWarn(@"Call loop detected, ignoring incoming call..."); + return nil; + } + + MLCallType callType = MLCallTypeAudio; + if([messageNode check:@"{urn:xmpp:jingle-message:0}propose/{urn:xmpp:jingle:apps:rtp:1}description"]) + callType = MLCallTypeVideo; + + MLCall* call = [[MLCall alloc] initWithUUID:uuid jmiid:jmiid contact:[MLContact createContactFromJid:messageNode.fromUser andAccountID:accountID] callType:callType andDirection:MLCallDirectionIncoming]; + //order matters here! + call.fullRemoteJid = messageNode.from; + call.jmiPropose = messageNode; + return call; +} + +@end diff --git a/Monal/Classes/MLWebViewController.h b/Monal/Classes/MLWebViewController.h new file mode 100644 index 0000000..937a294 --- /dev/null +++ b/Monal/Classes/MLWebViewController.h @@ -0,0 +1,17 @@ +// +// MLWebViewController.h +// Monal +// +// Created by Anurodh Pokharel on 1/1/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import +@import WebKit; + +@interface MLWebViewController : UIViewController + +-(void) initEmptyPage; +-(void) initViewWithUrl:(NSURL*) url; + +@end diff --git a/Monal/Classes/MLWebViewController.m b/Monal/Classes/MLWebViewController.m new file mode 100644 index 0000000..92f7b84 --- /dev/null +++ b/Monal/Classes/MLWebViewController.m @@ -0,0 +1,79 @@ +// +// MLWebViewController.m +// Monal +// +// Created by Anurodh Pokharel on 1/1/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import "MLWebViewController.h" +#import "HelperTools.h" + +@interface MLWebViewController () +@property (weak, nonatomic) IBOutlet WKWebView* webview; +@property (nonatomic, strong) NSURL* urltoLoad; +@end + +@implementation MLWebViewController + +-(void) viewDidLoad +{ + [super viewDidLoad]; + self.webview.contentMode = UIViewContentModeScaleAspectFill; + self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; + + UIBarButtonItem* openExternally = [[UIBarButtonItem alloc] init]; + openExternally.image = [UIImage systemImageNamed:@"safari"]; + [openExternally setTarget:self]; + [openExternally setAction:@selector(openExternally:)]; + [openExternally setIsAccessibilityElement:YES]; + [openExternally setAccessibilityLabel:NSLocalizedString(@"Open in default browser", @"")]; + self.navigationItem.rightBarButtonItems = [[NSArray alloc] initWithObjects:openExternally, nil]; +} + +-(void) openExternally:(id) sender +{ + DDLogDebug(@"Trying to open in default browser: %@", self.webview.URL); + if(self.webview.URL.fileURL) + { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", @"") message:NSLocalizedString(@"This is an embedded file that can not be opened externally.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action __unused) { + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; + } + else + [[UIApplication sharedApplication] performSelector:@selector(openURL:) withObject:self.webview.URL]; +} + +-(void) viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + if(self.urltoLoad.fileURL) + [self.webview loadFileURL:self.urltoLoad allowingReadAccessToURL:self.urltoLoad]; + else + { + NSMutableURLRequest* nsrequest = [NSMutableURLRequest requestWithURL: self.urltoLoad]; + if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) + nsrequest.requiresDNSSECValidation = YES; + [self.webview loadRequest:nsrequest]; + } + self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; +} + +-(void) didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; +} + +-(void) initEmptyPage +{ + [self initViewWithUrl:nil]; +} + +-(void) initViewWithUrl:(NSURL*) url +{ + self.urltoLoad = url; +} + +@end diff --git a/Monal/Classes/MLXEPSlashMeHandler.h b/Monal/Classes/MLXEPSlashMeHandler.h new file mode 100644 index 0000000..b00c372 --- /dev/null +++ b/Monal/Classes/MLXEPSlashMeHandler.h @@ -0,0 +1,32 @@ +// +// MLXEPSlashMeHandler.h +// Monal +// +// Created by jimtsai (poormusic2001@gmail.com) on 2020/9/16. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "DataLayer.h" + +NS_ASSUME_NONNULL_BEGIN + +@class MLMessage; +@class UIFont; + +@interface MLXEPSlashMeHandler : NSObject + ++ (MLXEPSlashMeHandler* )sharedInstance; + +/* + By using NSString without attributes. + */ +-(NSString*) stringSlashMeWithMessage:(MLMessage*) msg; + +/* +By using NSString with attributes. +*/ +-(NSMutableAttributedString*) attributedStringSlashMeWithMessage:(MLMessage*) msg andFont:(UIFont*) font; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLXEPSlashMeHandler.m b/Monal/Classes/MLXEPSlashMeHandler.m new file mode 100644 index 0000000..94113f1 --- /dev/null +++ b/Monal/Classes/MLXEPSlashMeHandler.m @@ -0,0 +1,54 @@ +// +// MLXEPSlashMeHandler.m +// Monal +// +// Created by jimtsai (poormusic2001@gmail.com) on 2020/9/16. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import "MLXEPSlashMeHandler.h" +#import "MLMessage.h" +#import "MLXMPPManager.h" + +@import UIKit.NSAttributedString; + +@implementation MLXEPSlashMeHandler + +#pragma mark initilization ++ (MLXEPSlashMeHandler* )sharedInstance +{ + static dispatch_once_t once; + static MLXEPSlashMeHandler* sharedInstance; + dispatch_once(&once, ^{ + sharedInstance = [MLXEPSlashMeHandler new] ; + }); + return sharedInstance; +} + +- (NSString*) stringSlashMeWithMessage:(MLMessage*) msg +{ + NSRange replacedRange = NSMakeRange(0, 3); + + NSString* displayName; + if(msg.inbound == NO) + displayName = [MLContact ownDisplayNameForAccount:[[MLXMPPManager sharedInstance] getEnabledAccountForID:msg.accountID]]; + else + displayName = msg.contactDisplayName; + + NSMutableString* replacedMessageText = [[NSMutableString alloc] initWithString:msg.messageText]; + NSMutableString* replacedName = [[NSMutableString alloc] initWithString:[NSString stringWithFormat:@"* %@", displayName]]; + + [replacedMessageText replaceCharactersInRange:replacedRange withString:replacedName]; + + return replacedMessageText; +} + +-(NSMutableAttributedString*) attributedStringSlashMeWithMessage:(MLMessage*) msg andFont:(UIFont*) font +{ + NSString* resultString = [self stringSlashMeWithMessage:msg]; + NSMutableAttributedString* replaceAttrMessageText = [[NSMutableAttributedString alloc] initWithString:resultString]; + [replaceAttrMessageText addAttribute:NSFontAttributeName value:font range:NSMakeRange(0, resultString.length)]; + return replaceAttrMessageText; +} + +@end diff --git a/Monal/Classes/MLXMLNode.h b/Monal/Classes/MLXMLNode.h new file mode 100644 index 0000000..ec10c7b --- /dev/null +++ b/Monal/Classes/MLXMLNode.h @@ -0,0 +1,93 @@ +// +// XMLNode.h +// Monal +// +// Created by Anurodh Pokharel on 6/29/13. +// +// + +#import +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MLXMLNode : NSObject +{ + +} + ++(BOOL) supportsSecureCoding; + +/** + Initilizes with an element type + */ +-(id) initWithElement:(NSString*) element; +-(id) initWithElement:(NSString*) element andNamespace:(NSString*) xmlns; +-(id) initWithElement:(NSString*) element andNamespace:(NSString*) xmlns withAttributes:(NSDictionary*) attributes andChildren:(NSArray*) children andData:(NSString* _Nullable) data; +-(id) initWithElement:(NSString*) element withAttributes:(NSDictionary*) attributes andChildren:(NSArray*) children andData:(NSString* _Nullable) data; +-(id) initWithElement:(NSString*) element andData:(NSString* _Nullable) data; +-(id) initWithElement:(NSString*) element andNamespace:(NSString*) xmlns andData:(NSString* _Nullable) data; + +/** + Query for text contents, elementNames, attributes or child elements + */ +-(NSArray*) find:(NSString* _Nonnull) queryString, ... NS_FORMAT_FUNCTION(1, 2); +-(id _Nullable) findFirst:(NSString* _Nonnull) queryString, ... NS_FORMAT_FUNCTION(1, 2); + +/** + Check if the current node matches the queryString and/or its extraction command would return something + */ +-(BOOL) check:(NSString* _Nonnull) queryString, ... NS_FORMAT_FUNCTION(1, 2); + +/** + Quickly set an XMLNS attribute + */ +-(void) setXMLNS:(NSString*) xmlns; + +-(id) shallowCopy; +-(id) shallowCopyWithData:(BOOL) copyData; + +/** + Generates an XML String suitable for writing based on the node + */ +@property (strong, readonly) NSString* XMLString; +@property (strong, readonly) NSString* description; + +/** + Adds a new child node (this creates a copy of the node and changes the copy's parent property to its new parent + */ +-(MLXMLNode* _Nullable) addChildNode:(MLXMLNode*) child; + +/** + Removes child by reference + */ +-(MLXMLNode* _Nullable) removeChildNode:(MLXMLNode*) child; + +/** + The name of the element itself. + */ +@property (atomic, strong, readonly) NSString* element; + +/** + Attributes are given keys as they will be printed in the XML + */ +@property (atomic, readonly) NSMutableDictionary* attributes; + +/** + Children are XMLnodes + */ +@property (atomic, readonly) NSArray* children; + +/** + String to be inserted into the data field between elements. AKA inner text. + */ +@property (atomic, strong) NSString* _Nullable data; + +/** + Parent node of this one (if any) + */ +@property (atomic, weak, readonly) MLXMLNode* _Nullable parent; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLXMLNode.m b/Monal/Classes/MLXMLNode.m new file mode 100644 index 0000000..cf670da --- /dev/null +++ b/Monal/Classes/MLXMLNode.m @@ -0,0 +1,848 @@ +// +// XMLNode.m +// Monal +// +// Created by Anurodh Pokharel on 6/29/13. +// +// + +#include + +#import "MLXMLNode.h" + +#import "HelperTools.h" +#import "XMPPIQ.h" +#import "XMPPMessage.h" +#import "XMPPPresence.h" +#import "XMPPDataForm.h" + +@import UIKit.UIApplication; + +//#define DEBUG_XMLQueryLanguage 1 + +//this is the required prototype from Holger's snprintf.c +int rpl_vasprintf(char **, const char *, va_list *); + +@interface MLXMLNode() +{ + NSMutableArray* _children; +} +@property (nonatomic, strong) NSCache* cache; +@property (nonatomic, strong) NSCache* queryEntryCache; + +@property (atomic, strong, readwrite) NSString* element; +@property (atomic, readwrite) NSMutableDictionary* attributes; +@property (atomic, weak, readwrite) MLXMLNode* parent; +@end + +@implementation MLXMLNode + +static NSRegularExpression* pathSplitterRegex; +static NSRegularExpression* componentParserRegex; +static NSRegularExpression* attributeFilterRegex; + +#ifdef QueryStatistics + static NSMutableDictionary* statistics; +#endif + ++(void) initialize +{ +#ifdef QueryStatistics + statistics = [NSMutableDictionary new]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(nowIdle:) name:kMonalIdle object:nil]; +#endif + + //compile regexes only once (see https://unicode-org.github.io/icu/userguide/strings/regexp.html for syntax) + pathSplitterRegex = [NSRegularExpression regularExpressionWithPattern:@"^(/?(\\{(\\*|[^}]+)\\})?([!a-zA-Z0-9_:-]+|\\*|\\.\\.)?((\\<[^=~]+[=~][^>]+\\>)*))((/((\\{(\\*|[^}]+)\\})?([!a-zA-Z0-9_:-]+|\\*|\\.\\.)?((\\<[^=~]+[=~][^>]+\\>)*)))*)((@[a-zA-Z0-9_:-]+|@@|#|\\$|\\\\[^\\\\]+\\\\)(\\|(bool|int|uint|double|datetime|base64|uuid|uuidcast))?)?$" options:NSRegularExpressionCaseInsensitive error:nil]; + componentParserRegex = [NSRegularExpression regularExpressionWithPattern:@"^(\\{(\\*|[^}]+)\\})?([!a-zA-Z0-9_:-]+|\\*|\\.\\.)?((\\<[^=~]+[=~][^>]+\\>)*)((@[a-zA-Z0-9_:-]+|@@|#|\\$|\\\\[^\\\\]+\\\\)(\\|(bool|int|uint|double|datetime|base64|uuid|uuidcast))?)?$" options:NSRegularExpressionCaseInsensitive error:nil]; + attributeFilterRegex = [NSRegularExpression regularExpressionWithPattern:@"\\<([^=~]+)([=~])([^>]+)\\>" options:NSRegularExpressionCaseInsensitive error:nil]; + +// testcases for stanza +// SCRAM-SHA-1PLAINSCRAM-SHA-1-PLUS +// [self print_debug:@"/*" inTree:parsedStanza]; +// [self print_debug:@"{*}*" inTree:parsedStanza]; +// [self print_debug:@"{*}*/*@xmlns" inTree:parsedStanza]; +// [self print_debug:@"{urn:ietf:params:xml:ns:xmpp-sasl}mechanisms" inTree:parsedStanza]; +// [self print_debug:@"{*}*@xmlns" inTree:parsedStanza]; +// [self print_debug:@"{urn:ietf:params:xml:ns:xmpp-sasl}mechanisms/mechanism" inTree:parsedStanza]; +// [self print_debug:@"{urn:ietf:params:xml:ns:xmpp-sasl}mechanisms/mechanism#" inTree:parsedStanza]; +// [self print_debug:@"{urn:ietf:params:xml:ns:xmpp-sasl}mechanisms/*#" inTree:parsedStanza]; +// [self print_debug:@"{urn:ietf:params:xml:ns:xmpp-sasl}mechanisms/*@xmlns" inTree:parsedStanza]; +// [self print_debug:@"/.." inTree:parsedStanza]; +// [self print_debug:@"/../*" inTree:parsedStanza]; +// [self print_debug:@"mechanisms/mechanism#" inTree:parsedStanza]; +// [self print_debug:@"{jabber:client}iq@@" inTree:parsedStanza]; +} + ++(void) nowIdle:(NSNotification*) notification +{ +#ifdef QueryStatistics + NSMutableDictionary* sortedStatistics = [NSMutableDictionary new]; + @synchronized(statistics) { + NSArray* sortedKeys = [statistics keysSortedByValueUsingComparator: ^(id obj1, id obj2) { + if([obj1 integerValue] > [obj2 integerValue]) + return (NSComparisonResult)NSOrderedDescending; + if([obj1 integerValue] < [obj2 integerValue]) + return (NSComparisonResult)NSOrderedAscending; + return (NSComparisonResult)NSOrderedSame; + }]; + for(NSString* key in sortedKeys) + DDLogDebug(@"STATISTICS: %@ = %@", key, statistics[key]); + //sortedStatistics[key] = statistics[key]; + } + //DDLogDebug(@"XML QUERY STATISTICS: %@", sortedStatistics); +#endif +} + +-(void) internalInit +{ + _attributes = [NSMutableDictionary new]; + _children = [NSMutableArray new]; + _parent = nil; + _data = nil; + _element = @""; + self.cache = [NSCache new]; + self.queryEntryCache = [NSCache new]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMemoryPressureNotification) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; +} + +-(id) init +{ + self = [super init]; + [self internalInit]; + return self; +} + +-(id) initWithElement:(NSString*) element +{ + self = [super init]; + [self internalInit]; + _element = [element copy]; + return self; +} + +-(id) initWithElement:(NSString*) element andNamespace:(NSString*) xmlns +{ + self = [self initWithElement:element]; + [self setXMLNS:xmlns]; + return self; +} + +-(id) initWithElement:(NSString*) element andNamespace:(NSString*) xmlns withAttributes:(NSDictionary*) attributes andChildren:(NSArray*) children andData:(NSString*) data +{ + self = [self initWithElement:element withAttributes:attributes andChildren:children andData:data]; + [self setXMLNS:xmlns]; + return self; +} + +-(id) initWithElement:(NSString*) element withAttributes:(NSDictionary*) attributes andChildren:(NSArray*) children andData:(NSString*) data +{ + self = [self initWithElement:element]; + [_attributes addEntriesFromDictionary:[[NSDictionary alloc] initWithDictionary:attributes copyItems:YES]]; + for(MLXMLNode* child in children) + [self addChildNode:child]; + _data = nil; + if(data) + _data = [data copy]; + return self; +} + +-(id) initWithElement:(NSString*) element andData:(NSString* _Nullable) data +{ + self = [self initWithElement:element withAttributes:@{} andChildren:@[] andData:data]; + return self; +} + +-(id) initWithElement:(NSString*) element andNamespace:(NSString*) xmlns andData:(NSString* _Nullable) data +{ + self = [self initWithElement:element withAttributes:@{} andChildren:@[] andData:data]; + [self setXMLNS:xmlns]; + return self; +} + +-(void) dealloc +{ +/* +#ifdef IS_ALPHA + DDLogVerbose(@"Dealloc of MLXMLNode: %@", self); +#endif +*/ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [self.cache removeAllObjects]; + [self.queryEntryCache removeAllObjects]; +} + +-(id) initWithCoder:(NSCoder*) decoder +{ + self = [super init]; + if(!self) + return nil; + [self internalInit]; + + _element = [decoder decodeObjectOfClass:[NSString class] forKey:@"element"]; + _attributes = [decoder decodeObjectOfClasses:[[NSSet alloc] initWithArray:@[[NSMutableDictionary class], [NSDictionary class], [NSMutableString class], [NSString class]]] forKey:@"attributes"]; + NSArray* decodedChildren = [decoder decodeObjectOfClasses:[[NSSet alloc] initWithArray:@[[NSMutableArray class], [NSArray class], [MLXMLNode class], [XMPPIQ class], [XMPPMessage class], [XMPPPresence class], [XMPPDataForm class]]] forKey:@"children"]; + for(MLXMLNode* child in decodedChildren) + [self addChildNodeWithoutCopy:child]; + _data = [decoder decodeObjectOfClass:[NSString class] forKey:@"data"]; + + return self; +} + +-(void) encodeWithCoder:(NSCoder*) encoder +{ + [encoder encodeObject:_element forKey:@"element"]; + [encoder encodeObject:_attributes forKey:@"attributes"]; + [encoder encodeObject:_children forKey:@"children"]; + [encoder encodeObject:_data forKey:@"data"]; +} + ++(BOOL) supportsSecureCoding +{ + return YES; +} + +-(id) copyWithZone:(NSZone*) zone +{ + MLXMLNode* copy = [[[self class] alloc] initWithElement:[_element copy]]; + copy.attributes = [[NSMutableDictionary alloc] initWithDictionary:_attributes copyItems:YES]; + for(MLXMLNode* child in _children) + [copy addChildNode:child]; + copy.data = _data ? [_data copy] : nil; + return copy; +} + +-(id) shallowCopy +{ + return [self shallowCopyWithData:NO]; +} + +-(id) shallowCopyWithData:(BOOL) copyData +{ + MLXMLNode* copy = [[[self class] alloc] initWithElement:[_element copy]]; + copy.attributes = [[NSMutableDictionary alloc] initWithDictionary:_attributes copyItems:YES]; + if(copyData) + copy.data = _data ? [_data copy] : nil; + return copy; +} + +-(void) handleMemoryPressureNotification +{ + [self.cache removeAllObjects]; + [self.queryEntryCache removeAllObjects]; + DDLogVerbose(@"Removed all cached objects in this MLXMLNode due to memory pressure"); + DDLogVerbose(@"Node: %@", self); +} + +-(void) setXMLNS:(NSString*) xmlns +{ + [_attributes setObject:[xmlns copy] forKey:@"xmlns"]; +} + +-(MLXMLNode*) addChildNode:(MLXMLNode*) child +{ + if(nilExtractor(child) == nil) + return nil; + return [self addChildNodeWithoutCopy:[child copy]]; +} + +//only used by MLBaseParser to add new childs without deep-copying the object +-(MLXMLNode*) addChildNodeWithoutCopy:(MLXMLNode*) child +{ + if(!child) + return nil; + MLXMLNode* insertedChild = child; + insertedChild.parent = self; + //namespace inheritance (will be stripped by XMLString later on) + //we do this here to make sure manual created nodes always have a namespace like the nodes created by the xml parser do + if(!insertedChild.attributes[@"xmlns"]) + insertedChild.attributes[@"xmlns"] = [_attributes[@"xmlns"] copy]; + [_children addObject:insertedChild]; + [self invalidateUpstreamCache]; + //this one can be removed if the query path component ".." is removed from our language + [insertedChild invalidateDownstreamCache]; + return insertedChild; +} + +-(MLXMLNode*) removeChildNode:(MLXMLNode*) child +{ + MLXMLNode* foundChild = nil; + if(!child) + return foundChild; + NSInteger index = [_children indexOfObject:child]; + if(index != NSNotFound) + { + foundChild = [_children objectAtIndex:index]; + foundChild.parent = nil; + [_children removeObjectAtIndex:index]; + [self invalidateUpstreamCache]; + } + return foundChild; +} + +-(NSArray*) children +{ + return [NSArray arrayWithArray:_children]; +} + +-(void) invalidateUpstreamCache +{ + //invalidate caches of all nodes upstream in our tree + for(MLXMLNode* node = self; node; node = node.parent) + [node.cache removeAllObjects]; +} + +-(void) invalidateDownstreamCache +{ + [self.cache removeAllObjects]; + for(MLXMLNode* node in _children) + [node invalidateDownstreamCache]; +} + +//query language similar to the one prosody uses (which in turn is loosely based on xpath) +//this implements a strict superset of prosody's language which makes it possible to use queries from prosody directly +//unlinke the language used in prosody, this returns *all* nodes mathching the query (use findFirst to get only the first match like prosody does) +//see https://prosody.im/doc/developers/util/stanza (function stanza:find(path)) for examples and description +//extensions to prosody's language: +//we extended this language to automatically infer the namespace from the parent element, if no namespace was given explicitly in the query +//we also added support for "*" as element name or namespace meaning "any nodename" / "any namespace" +//the additional ".." element name can be used to ascend to the parent node and do a find() on this node using the rest of the query path +//if you begin a path with "/" that means "begin with checking the current element", if your path does not begin with a "/" +//this means "begin witch checking the children of this node" (normal prosody behaviour) +//we also added additional extraction commands ("@attrName" and "#" are extraction commands defined within prosody): +//extraction command "$" returns the name of the XML element (just like "#" returns its text content) +//the argument "@" for extraction command "@" returns the full attribute dictionary of the XML element (full command: "@@") +//we also added conversion commands that can be appended to a query string: +//"|bool" --> convert xml string to NSNumber containing bool (XMPP defines "1"/"true" to be true and "0"/"false" to be false) +//"|int" --> convert xml string to NSNumber containing NSInteger +//"|uint" --> convert xml string to NSNumber containing NSUInteger +//"|double" --> convert xml string to NSNumber containing double +//"|datetime" --> convert xml datetime string to NSDate +//"|base64" --> convert base64 encoded xml string to NSData +//"|uuid" --> interprete xml string as NSUUID +//"|uuidcast" --> try to interprete xml string as UUID and convert xml string to NSUUID via sha256 transformation, if not +-(NSArray*) find:(NSString* _Nonnull) queryString, ... NS_FORMAT_FUNCTION(1, 2) +{ + va_list args; + va_start(args, queryString); + NSArray* retval = [self find:queryString arguments:&args]; + va_end(args); + return retval; +} + +//like find: above, but only return the first match +-(id) findFirst:(NSString* _Nonnull) queryString, ... NS_FORMAT_FUNCTION(1, 2) +{ + va_list args; + va_start(args, queryString); + id retval = [self find:queryString arguments:&args].firstObject; + va_end(args); + return retval; +} + +//like findFirst, but only check if it would return something +-(BOOL) check:(NSString* _Nonnull) queryString, ... NS_FORMAT_FUNCTION(1, 2) +{ + va_list args; + va_start(args, queryString); + BOOL retval = [self find:queryString arguments:&args].firstObject != nil ? YES : NO; + va_end(args); + return retval; +} + +-(NSArray*) find:(NSString* _Nonnull) queryString arguments:(va_list*) args +{ + //return our own node if the query string is empty (this makes queries like "/.." possible which will return the parent node + if(!queryString || [queryString isEqualToString:@""]) + return @[self]; + + va_list cacheKeyArgs; + va_copy(cacheKeyArgs, *args); + NSString* cacheKey = [NSString stringWithFormat:@"%@§§%@", queryString, [[NSString alloc] initWithFormat:queryString arguments:cacheKeyArgs]]; + va_end(cacheKeyArgs); +#ifdef DEBUG_XMLQueryLanguage + DDLogVerbose(@"Cache key: %@", cacheKey); +#endif + + //return results from cache if possible + NSArray* cacheObj = nil; + WeakContainer* cacheEntryContainer = [self.cache objectForKey:cacheKey]; + if(cacheEntryContainer != nil) + cacheObj = cacheEntryContainer.obj; + if(cacheObj != nil) + { +#ifdef DEBUG_XMLQueryLanguage + DDLogVerbose(@"Returning cached result: %@", cacheObj); +#endif + return cacheObj; + } + +#ifdef QueryStatistics + @synchronized(statistics) { + if(!statistics[queryString]) + statistics[queryString] = @0; + statistics[queryString] = [NSNumber numberWithInteger:[statistics[queryString] integerValue] + 1]; + } +#endif + + //shortcut syntax for queries operating directly on this node + //this translates "/@attr", "/#" or "/$" into their correct form "/{*}*@attr", "/{*}*#" or "/{*}*$" + if( + [queryString characterAtIndex:0] == '/' && + [queryString length] >=2 && + [[NSCharacterSet characterSetWithCharactersInString:@"@#$<"] characterIsMember:[queryString characterAtIndex:1]] + ) + queryString = [NSString stringWithFormat:@"/{*}*%@", [queryString substringFromIndex:1]]; + + NSArray* results; + //check if the current element our our children should be queried "/" makes the path "absolute" instead of "relative" + if([[queryString substringToIndex:1] isEqualToString:@"/"]) + results = [self find:[queryString substringFromIndex:1] inNodeList:@[self] arguments:args]; //absolute path (check self first) + else + results = [self find:queryString inNodeList:_children arguments:args]; //relative path (check childs first) + + //update cache and return results + [self.cache setObject:[[WeakContainer alloc] initWithObj:results] forKey:cacheKey]; //use weak container to break retain circle + return results; +} + +-(NSArray*) find:(NSString* _Nonnull) queryString inNodeList:(NSArray* _Nonnull) nodesToCheck arguments:(va_list*) args +{ + //shortcut for empty nodesToCheck + if(![nodesToCheck count]) + return @[]; + NSMutableArray* results = [NSMutableArray new]; + //split our path into first component and rest + NSArray* matches = [pathSplitterRegex matchesInString:queryString options:0 range:NSMakeRange(0, [queryString length])]; + if(![matches count]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"XML query has syntax errors (no matches for path splitter regex)!" userInfo:@{ + @"self": self, + @"queryString": queryString, + }]; + NSTextCheckingResult* match = matches.firstObject; + NSRange pathComponent1Range = [match rangeAtIndex:1]; + NSRange pathComponent2Range = [match rangeAtIndex:7]; + NSRange pathComponent3Range = [match rangeAtIndex:15]; + NSString* pathComponent1 = @""; + NSString* pathComponent2 = @""; + NSString* pathComponent3 = @""; + if(pathComponent1Range.location != NSNotFound && pathComponent1Range.length > 0) + pathComponent1 = [queryString substringWithRange:pathComponent1Range]; + if(pathComponent2Range.location != NSNotFound && pathComponent2Range.length > 0) + pathComponent2 = [queryString substringWithRange:pathComponent2Range]; + if(pathComponent3Range.location != NSNotFound && pathComponent3Range.length > 0) + pathComponent3 = [queryString substringWithRange:pathComponent3Range]; + + NSString* pathComponent = pathComponent1; + NSString* rest = @""; + if(![pathComponent2 length]) + pathComponent = [NSString stringWithFormat:@"%@%@", pathComponent1, pathComponent3]; + else + rest = [NSString stringWithFormat:@"%@%@", [pathComponent2 substringFromIndex:1], pathComponent3]; + NSMutableDictionary* parsedEntry = [self parseQueryEntry:pathComponent arguments:args]; + + //check if the parent element was selected and ask our parent to check the rest of our query path if needed + if([pathComponent isEqualToString:@".."]) + { + MLXMLNode* parent = self.parent; + if(!parent) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"XML query tries to ascend to non-existent parent element!" userInfo:@{ + @"self": self, + @"queryString": queryString, + @"pathComponent": pathComponent, + @"parsedEntry": parsedEntry + }]; + return [parent find:rest arguments:args]; + } + + //shortcut for dataform subqueries: allow empty element names and namespaces, they get autofilled with {jabber:x:data}x + if(!parsedEntry[@"elementName"] && !parsedEntry[@"namespace"] && [parsedEntry[@"extractionCommand"] isEqualToString:@"\\"]) + { + parsedEntry[@"elementName"] = @"x"; + parsedEntry[@"namespace"] = @"jabber:x:data"; + } + + if(!parsedEntry[@"elementName"] && !parsedEntry[@"namespace"]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"XML queries must not contain a path component having neither element name nor namespace!" userInfo:@{ + @"self": self, + @"queryString": queryString, + @"pathComponent": pathComponent, + @"parsedEntry": parsedEntry + }]; + + //"*" is just syntactic sugar for an empty element name + //(but empty element names are not allowed if no namespace was given, which makes this sugar mandatory in this case) + if(parsedEntry[@"elementName"] && [parsedEntry[@"elementName"] isEqualToString:@"*"]) + [parsedEntry removeObjectForKey:@"elementName"]; + + //if no namespace was given, use the parent one (no namespace means the namespace will be inherited) + //this will allow all namespaces "{*}" if the nodes in nodesToCheck don't have a parent at all + if((!parsedEntry[@"namespace"] || [parsedEntry[@"namespace"] isEqualToString:@""]) && ((MLXMLNode*)nodesToCheck[0]).parent) + parsedEntry[@"namespace"] = ((MLXMLNode*)nodesToCheck[0]).parent.attributes[@"xmlns"]; //all nodesToCheck have the same parent, just pick the first one + + //"*" is just syntactic sugar for an empty namespace name which means "any namespace allowed" + //(but empty namespaces are only allowed in internal methods, which makes this sugar mandatory) + //don't confuse this with a query without namespace which will result in a query using the parent's namespace, not "any namespace allowed"! + if(parsedEntry[@"namespace"] && [parsedEntry[@"namespace"] isEqualToString:@"*"]) + [parsedEntry removeObjectForKey:@"namespace"]; + + //element names can be negated + BOOL negatedElementName = NO; + if(parsedEntry[@"elementName"] && [parsedEntry[@"elementName"] characterAtIndex:0] == '!') + { + negatedElementName = YES; + parsedEntry[@"elementName"] = [parsedEntry[@"elementName"] substringFromIndex:1]; + } + + //iterate through nodesToCheck (containing only us, our parent's children or our own children) + //and check if they match the current path component (e.g. parsedEntry) + for(MLXMLNode* node in nodesToCheck) + { + //check element name and namespace (if given) + if( + ( + (negatedElementName && ![parsedEntry[@"elementName"] isEqualToString:node.element]) || + (!parsedEntry[@"elementName"] || [parsedEntry[@"elementName"] isEqualToString:node.element]) + ) && + (!parsedEntry[@"namespace"] || [parsedEntry[@"namespace"] isEqualToString:node.attributes[@"xmlns"]]) + ) { + //check for attribute filters (if given) + if(parsedEntry[@"attributeFilters"] && [parsedEntry[@"attributeFilters"] count]) + { + BOOL ok = YES; + for(NSDictionary* filter in parsedEntry[@"attributeFilters"]) + { + if(node.attributes[filter[@"name"]]) + { + NSArray* matches = [filter[@"value"] matchesInString:node.attributes[filter[@"name"]] options:0 range:NSMakeRange(0, [node.attributes[filter[@"name"]] length])]; + if(![matches count]) + { + ok = NO; //this node does *not* fullfill the attribute filter regex + break; + } + } + else + { + ok = NO; + break; + } + } + if(!ok) + continue; //this node does *not* fullfill the attribute filter regex + } + //check if we should process an extraction command (only allowed if we're at the end of the query) + if(parsedEntry[@"extractionCommand"]) + { + //sanity check + if([rest length] > 0) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Extraction commands are only allowed for terminal nodes of XML queries!" userInfo:@{ + @"self": self, + @"node": node, + @"queryString": queryString, + @"pathComponent": pathComponent, + @"parsedEntry": parsedEntry + }]; + + id singleResult = nil; + if([parsedEntry[@"extractionCommand"] isEqualToString:@"#"] && node.data) + singleResult = [self processConversionCommand:parsedEntry[@"conversionCommand"] forXMLString:node.data]; + else if([parsedEntry[@"extractionCommand"] isEqualToString:@"@"] && node.attributes[parsedEntry[@"attribute"]]) + singleResult = [self processConversionCommand:parsedEntry[@"conversionCommand"] forXMLString:node.attributes[parsedEntry[@"attribute"]]]; + else if([parsedEntry[@"extractionCommand"] isEqualToString:@"$"] && node.element) + singleResult = [self processConversionCommand:parsedEntry[@"conversionCommand"] forXMLString:node.element]; + else if([parsedEntry[@"extractionCommand"] isEqualToString:@"\\"]) + { + if(![node respondsToSelector:NSSelectorFromString(@"processDataFormQuery:")]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Data form extractions can only be used on data forms! This exception means you have a bug somewhere else in your code (probably at the source of the element you are trying to use in your data form query)!" userInfo:@{ + @"self": self, + @"node": node, + @"queryString": queryString, + @"pathComponent": pathComponent, + @"parsedEntry": parsedEntry + }]; + //faster than NSMethodInvocation, but way less readable, see https://stackoverflow.com/a/20058585/3528174 + id extraction = ((id (*)(id, SEL, NSString*))[node methodForSelector:NSSelectorFromString(@"processDataFormQuery:")])(node, NSSelectorFromString(@"processDataFormQuery:"), parsedEntry[@"dataFormQuery"]); + if(extraction) //only add this to our results if the data form query succeeded + { + //check if we try to operate a conversion command on something not a single extracted simple form field of type NSString + if(parsedEntry[@"conversionCommand"] && ![extraction isKindOfClass:[NSString class]]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Conversion commands can not be used on data form extractions returning the whole data form or an NSArray/NSDictionary!" userInfo:@{ + @"self": self, + @"node": node, + @"queryString": queryString, + @"pathComponent": pathComponent, + @"parsedEntry": parsedEntry + }]; + singleResult = [self processConversionCommand:parsedEntry[@"conversionCommand"] forXMLString:(NSString*)extraction]; + } + } + else if([parsedEntry[@"extractionCommand"] isEqualToString:@"@"] && [parsedEntry[@"attribute"] isEqualToString:@"@"]) + { + if(parsedEntry[@"conversionCommand"]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Conversion commands can not be used on attribute dict extractions (e.g. extraction command '@@')!" userInfo:@{ + @"self": self, + @"node": node, + @"queryString": queryString, + @"pathComponent": pathComponent, + @"parsedEntry": parsedEntry + }]; + singleResult = node.attributes; + } + if(singleResult) + [results addObject:singleResult]; + } + else + { + if(parsedEntry[@"conversionCommand"]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Conversion commands are only allowed for terminal nodes of XML queries that use an extraction command!" userInfo:@{ + @"self": self, + @"node": node, + @"queryString": queryString, + @"pathComponent": pathComponent, + @"parsedEntry": parsedEntry + }]; + if([rest length] > 0) //we should descent to this node + [results addObjectsFromArray:[node find:rest arguments:args]]; //this will cache the subquery on this node, too + else //we should not descent to this node (we reached the end of our query) + [results addObject:node]; + } + } + } + + //DDLogVerbose(@"*** DEBUG(%@)[%@] ***\n%@\n%@\n%@", queryString, pathComponent, parsedEntry, results, nodesToCheck); + return [results copy]; //return readonly copy of results +} + +-(id) processConversionCommand:(NSString*) command forXMLString:(NSString* _Nonnull) string +{ + if(!string) + return nil; + if([command isEqualToString:@"bool"]) + { + //xml bools as defined in xmpp core RFC + if([string isEqualToString:@"1"] || [string isEqualToString:@"true"]) + return @YES; + else if([string isEqualToString:@"0"] || [string isEqualToString:@"false"]) + return @NO; + else + return @NO; //no bool at all, return false + } + else if([command isEqualToString:@"int"]) + return [NSNumber numberWithInteger:(NSInteger)[string integerValue]]; + else if([command isEqualToString:@"uint"]) + return [NSNumber numberWithUnsignedInteger:(NSUInteger)[string longLongValue]]; + else if([command isEqualToString:@"double"]) + return [NSNumber numberWithDouble:[string doubleValue]]; + else if([command isEqualToString:@"datetime"]) + return [HelperTools parseDateTimeString:string]; + else if([command isEqualToString:@"base64"]) + return [HelperTools dataWithBase64EncodedString:string]; + else if([command isEqualToString:@"uuid"]) + return [[NSUUID alloc] initWithUUIDString:string]; + else if([command isEqualToString:@"uuidcast"]) + { + NSUUID* uuid = [[NSUUID alloc] initWithUUIDString:string]; + if(uuid != nil) + return uuid; + return [HelperTools stringToUUID:string]; + } + else + return string; +} + +-(NSMutableDictionary*) parseQueryEntry:(NSString* _Nonnull) entry arguments:(va_list*) args +{ + va_list cacheKeyArgs; + va_copy(cacheKeyArgs, *args); + NSString* cacheKey = [NSString stringWithFormat:@"%@§§%@", entry, [[NSString alloc] initWithFormat:entry arguments:cacheKeyArgs]]; + va_end(cacheKeyArgs); +#ifdef DEBUG_XMLQueryLanguage + DDLogVerbose(@"Cache key: %@", cacheKey); +#endif + + //return results from cache if possible + NSDictionary* cacheEntry = [self.queryEntryCache objectForKey:cacheKey]; + if(cacheEntry != nil) + { +#ifdef DEBUG_XMLQueryLanguage + DDLogDebug(@"Returning cached result: %@", cacheEntry); +#endif + return [cacheEntry mutableCopy]; + } + + NSMutableDictionary* retval = [NSMutableDictionary new]; + NSArray* matches = [componentParserRegex matchesInString:entry options:0 range:NSMakeRange(0, [entry length])]; + if(![matches count]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Could not parse path component!" userInfo:@{ + @"self": self, + @"queryEntry": entry + }]; + NSTextCheckingResult* match = matches.firstObject; + NSRange namespaceRange = [match rangeAtIndex:2]; + NSRange elementNameRange = [match rangeAtIndex:3]; + NSRange attributeFilterRange = [match rangeAtIndex:4]; + NSRange extractionCommandRange = [match rangeAtIndex:7]; + NSRange conversionCommandRange = [match rangeAtIndex:9]; + if(namespaceRange.location != NSNotFound) + retval[@"namespace"] = [entry substringWithRange:namespaceRange]; + if(elementNameRange.location != NSNotFound) + retval[@"elementName"] = [entry substringWithRange:elementNameRange]; + if(attributeFilterRange.location != NSNotFound && attributeFilterRange.length > 0) + { + retval[@"attributeFilters"] = [NSMutableArray new]; + NSString* attributeFilters = [entry substringWithRange:attributeFilterRange]; +#ifdef DEBUG_XMLQueryLanguage + DDLogDebug(@"Extracting attribute filters: '%@'...", attributeFilters); +#endif + NSArray* attributeFilterMatches = [attributeFilterRegex matchesInString:attributeFilters options:0 range:NSMakeRange(0, [attributeFilters length])]; + for(NSTextCheckingResult* attributeFilterMatch in attributeFilterMatches) + { + NSRange attributeFilterNameRange = [attributeFilterMatch rangeAtIndex:1]; + NSRange attributeFilterTypeRange = [attributeFilterMatch rangeAtIndex:2]; + NSRange attributeFilterValueRange = [attributeFilterMatch rangeAtIndex:3]; + if(attributeFilterNameRange.location == NSNotFound || attributeFilterTypeRange.location == NSNotFound || attributeFilterValueRange.location == NSNotFound) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Attribute filter not complete!" userInfo:@{ + @"self": self, + @"queryEntry": entry, + @"attributeFilters": attributeFilters + }]; + + NSString* attributeFilterName = [attributeFilters substringWithRange:attributeFilterNameRange]; + unichar attributeFilterType = [[attributeFilters substringWithRange:attributeFilterTypeRange] characterAtIndex:0]; + NSString* attributeFilterValue = [attributeFilters substringWithRange:attributeFilterValueRange]; + + NSString* attributeFilterValueRegexPattern; + if(attributeFilterType == '=') //verbatim comparison using format string interpolation + { + //substitute format string specifiers inside of our attribute filter string. + //use Holger's vsnprintf() which got pimped up to support %@ format specifier. + //use va_list* everywhere to make sure we move the same va_list pointer in every invocation here + //instead of starting with a fresh copy (which would always extract only the first variadic argument + //regardless of the position in the format string we are at). + char* dest = NULL; + if(rpl_vasprintf(&dest, [attributeFilterValue UTF8String], args) == -1) + [NSException raise:@"NSInternalInconsistencyException" format:@"failed malloc in MLXMLNode's usage of rpl_vasprintf" arguments:nil]; + MLAssert(dest != NULL, @"dest should *never* be NULL!"); + NSString* unescapedAttributeFilterValue = [NSString stringWithUTF8String:dest]; + free(dest); + + NSString* escapedAttributeFilterValue = [NSRegularExpression escapedPatternForString:unescapedAttributeFilterValue]; + attributeFilterValueRegexPattern = [NSString stringWithFormat:@"^%@$", escapedAttributeFilterValue]; +#ifdef DEBUG_XMLQueryLanguage + DDLogDebug(@"unescapedAttributeFilterValue: '%@'", unescapedAttributeFilterValue); + DDLogDebug(@"escapedAttributeFilterValue: '%@'", escapedAttributeFilterValue); + DDLogDebug(@"attributeFilterValueRegexPattern: '%@'", attributeFilterValueRegexPattern); +#endif + } + else if(attributeFilterType == '~') //raw regex comparison *without* format string interpolation + //you will have to include sring-start and string-end markers yourself as well as all other regex stuff + attributeFilterValueRegexPattern = attributeFilterValue; + else + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Internal attribute filter bug, this should never happen!" userInfo:@{ + @"self": self, + @"queryEntry": entry, + @"attributeFilters": attributeFilters + }]; + + NSError* error; + [retval[@"attributeFilters"] addObject:@{ + @"name": attributeFilterName, + //this regex will be cached in parsed form in the local cache of this method + @"value": [NSRegularExpression regularExpressionWithPattern:attributeFilterValueRegexPattern options:NSRegularExpressionCaseInsensitive error:&error] + }]; + if(error) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Attribute filter regex can not be compiled!" userInfo:@{ + @"self": self, + @"queryEntry": entry, + @"filterType": @(attributeFilterType), + @"filterName": attributeFilterName, + @"filterValue": attributeFilterValue, + @"error": error + }]; + } +#ifdef DEBUG_XMLQueryLanguage + DDLogDebug(@"Done extracting, attributeFilters are now: %@", retval[@"attributeFilters"]); +#endif + } + if(extractionCommandRange.location != NSNotFound) + { + NSString* extractionCommand = [entry substringWithRange:extractionCommandRange]; + retval[@"extractionCommand"] = [extractionCommand substringToIndex:1]; + unichar command = [extractionCommand characterAtIndex:0]; + if(command == '@') + retval[@"attribute"] = [extractionCommand substringFromIndex:1]; + if(command == '\\') + retval[@"dataFormQuery"] = [extractionCommand substringWithRange:NSMakeRange(1, extractionCommandRange.length-2)]; + } + if(conversionCommandRange.location != NSNotFound) + retval[@"conversionCommand"] = [entry substringWithRange:conversionCommandRange]; + [self.queryEntryCache setObject:[retval copy] forKey:cacheKey]; + return retval; +} + ++(NSString*) escapeForXMPP:(NSString*) targetString +{ + NSMutableString* mutable = [targetString mutableCopy]; + [mutable replaceOccurrencesOfString:@"&" withString:@"&" options:NSLiteralSearch range:NSMakeRange(0, mutable.length)]; + [mutable replaceOccurrencesOfString:@"<" withString:@"<" options:NSLiteralSearch range:NSMakeRange(0, mutable.length)]; + [mutable replaceOccurrencesOfString:@">" withString:@">" options:NSLiteralSearch range:NSMakeRange(0, mutable.length)]; + [mutable replaceOccurrencesOfString:@"'" withString:@"'" options:NSLiteralSearch range:NSMakeRange(0, mutable.length)]; + [mutable replaceOccurrencesOfString:@"\"" withString:@""" options:NSLiteralSearch range:NSMakeRange(0, mutable.length)]; + return [mutable copy]; +} + +-(NSString*) XMLString +{ + if(!_element) + return @""; // sanity check + + //special handling of xml start tag + if([_element isEqualToString:@"__xml"]) + return [NSString stringWithFormat:@""]; + + NSMutableString* outputString = [NSMutableString new]; + [outputString appendString:[NSString stringWithFormat:@"<%@", _element]]; + + //set attributes + MLXMLNode* parent = self.parent; + for(NSString* key in [_attributes allKeys]) + { + //handle xmlns inheritance (don't add namespace to childs if it should be the same like the parent's one) + if([key isEqualToString:@"xmlns"] && parent && [[NSString stringWithFormat:@"%@", _attributes[@"xmlns"]] isEqualToString:[NSString stringWithFormat:@"%@", parent.attributes[@"xmlns"]]]) + continue; + [outputString appendString:[NSString stringWithFormat:@" %@='%@'", key, [MLXMLNode escapeForXMPP:[NSString stringWithFormat:@"%@", _attributes[key]]]]]; + } + + if([_children count] || (_data && ![_data isEqualToString:@""])) + { + [outputString appendString:[NSString stringWithFormat:@">"]]; + + //set children here + for(MLXMLNode* child in _children) + [outputString appendString:[child XMLString]]; + + if(_data) + [outputString appendString:[MLXMLNode escapeForXMPP:_data]]; + + //dont close stream element + if(![_element isEqualToString:@"stream:stream"] && ![_element isEqualToString:@"/stream:stream"]) + [outputString appendString:[NSString stringWithFormat:@"", _element]]; + } + else + { + //dont close stream element + if(![_element isEqualToString:@"stream:stream"] && ![_element isEqualToString:@"/stream:stream"]) + [outputString appendString:[NSString stringWithFormat:@"/>"]]; + else + [outputString appendString:[NSString stringWithFormat:@">"]]; + } + + return (NSString*)outputString; +} + +-(NSString*) description +{ + return [self XMLString]; +} + +@end diff --git a/Monal/Classes/MLXMPPConnection.h b/Monal/Classes/MLXMPPConnection.h new file mode 100644 index 0000000..7290bef --- /dev/null +++ b/Monal/Classes/MLXMPPConnection.h @@ -0,0 +1,75 @@ +// +// MLXMPPConnection.h +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import +#import "MLXMPPServer.h" +#import "MLXMPPIdentity.h" + +NS_ASSUME_NONNULL_BEGIN + +@class MLContactSoftwareVersionInfo; +@class MLXMLNode; + +/** + A class to hold the the identity, host, state and discovered properties of an xmpp connection + */ +@interface MLXMPPConnection : NSObject + +@property (nonatomic, readonly) MLXMPPServer* server; +@property (nonatomic, readonly) MLXMPPIdentity* identity; + +//State + +/** + The properties below are discovered after connecting and therefore are not read only + */ + +//server details +@property (nonatomic, strong) MLXMLNode* serverFeatures; +@property (nonatomic, strong) NSSet* accountDiscoFeatures; +@property (nonatomic, strong) NSSet* serverDiscoFeatures; +@property (nonatomic, strong) NSDictionary* serverContactAddresses; + +@property (nonatomic, strong) NSMutableArray* discoveredServices; +@property (nonatomic, strong) NSMutableArray* discoveredStunTurnServers; +@property (nonatomic, strong) NSMutableDictionary* discoveredAdhocCommands; +@property (nonatomic, strong) MLContactSoftwareVersionInfo* _Nullable serverVersion; + +@property (nonatomic, strong) NSMutableDictionary* conferenceServers; +@property (nonatomic, readonly) NSArray* conferenceServerIdentities; + +@property (nonatomic, assign) BOOL supportsHTTPUpload; +@property (nonatomic, strong) NSString* _Nullable uploadServer; +@property (nonatomic, assign) NSInteger uploadSize; + +@property (nonatomic, assign) BOOL supportsSM3; +@property (nonatomic, assign) BOOL pushEnabled; +@property (nonatomic, assign) BOOL supportsBookmarksCompat; +@property (nonatomic, assign) BOOL usingCarbons2; +@property (nonatomic, strong) NSString* serverIdentity; + +@property (nonatomic, readonly) BOOL supportsRosterVersioning; +@property (nonatomic, readonly) BOOL supportsClientState; +@property (nonatomic, readonly) BOOL supportsRosterPreApproval; + +@property (nonatomic, assign) BOOL supportsPubSub; +@property (nonatomic, assign) BOOL supportsPubSubMax; +@property (nonatomic, assign) BOOL supportsModernPubSub; + +@property (nonatomic, assign) BOOL accountDiscoDone; + +@property (nonatomic, strong) NSDictionary* saslMethods; +@property (nonatomic, strong) NSDictionary* channelBindingTypes; +@property (nonatomic, assign) BOOL supportsSSDP; +@property (nonatomic, strong) NSString* tlsVersion; + +-(id) initWithServer:(MLXMPPServer*) server andIdentity:(MLXMPPIdentity*) identity; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLXMPPConnection.m b/Monal/Classes/MLXMPPConnection.m new file mode 100644 index 0000000..6e9da20 --- /dev/null +++ b/Monal/Classes/MLXMPPConnection.m @@ -0,0 +1,67 @@ +// +// MLXMPPConnection.m +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLXMPPConnection.h" +#import "MLXMLNode.h" + +@interface MLXMPPConnection () + +@property (nonatomic) MLXMPPServer* server; +@property (nonatomic) MLXMPPIdentity* identity; + +@end + +@implementation MLXMPPConnection + +-(id) initWithServer:(MLXMPPServer*) server andIdentity:(MLXMPPIdentity*) identity +{ + self = [super init]; + self.server = server; + self.identity = identity; + self.serverFeatures = [MLXMLNode new]; + self.accountDiscoFeatures = [NSSet new]; + self.serverDiscoFeatures = [NSSet new]; + self.serverContactAddresses = [NSDictionary new]; + self.conferenceServers = [NSMutableDictionary new]; + self.discoveredServices = [NSMutableArray new]; + self.discoveredStunTurnServers = [NSMutableArray new]; + self.discoveredAdhocCommands = [NSMutableDictionary new]; + self.serverVersion = nil; + return self; +} + +-(BOOL) supportsRosterVersioning +{ + return [self.serverFeatures check:@"{urn:xmpp:features:rosterver}ver"]; +} + +-(BOOL) supportsClientState +{ + return [self.serverFeatures check:@"{urn:xmpp:csi:0}csi"]; +} + +-(BOOL) supportsRosterPreApproval +{ + return [self.serverFeatures check:@"{urn:xmpp:features:pre-approval}sub"]; +} + +-(NSArray*) conferenceServerIdentities +{ + NSMutableArray* result = [NSMutableArray array]; + + for (NSString* jid in self.conferenceServers) { + NSDictionary* entry = [self.conferenceServers[jid] findFirst:@"identity@@"]; + NSMutableDictionary* mutableEntry = [entry mutableCopy]; + mutableEntry[@"jid"] = jid; + [result addObject:mutableEntry]; + } + + return [result copy]; +} + +@end diff --git a/Monal/Classes/MLXMPPIdentity.h b/Monal/Classes/MLXMPPIdentity.h new file mode 100644 index 0000000..f116be7 --- /dev/null +++ b/Monal/Classes/MLXMPPIdentity.h @@ -0,0 +1,40 @@ +// +// MLXMPPIdentity.h +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Imutable class to contain the specifics of an XMPP user + */ +@interface MLXMPPIdentity : NSObject + +@property (atomic) NSString* jid; +@property (atomic) NSString* resource; +@property (atomic, readonly) NSString* fullJid; + +@property (atomic, readonly) NSString* user; +@property (atomic, readonly) NSString* password; +@property (atomic, readonly) NSString* domain; + +/** + Creates a new identity. + */ +-(id) initWithJid:(nonnull NSString *)jid password:(nonnull NSString *) password andResource:(nonnull NSString *) resource; + +/** + Update password is only used when the password is changed in app + */ +-(void) updatPassword:(NSString *) newPassword; + +-(void) bindJid:(NSString*) jid; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLXMPPIdentity.m b/Monal/Classes/MLXMPPIdentity.m new file mode 100644 index 0000000..4715159 --- /dev/null +++ b/Monal/Classes/MLXMPPIdentity.m @@ -0,0 +1,64 @@ +// +// MLXMPPIdentity.m +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLXMPPIdentity.h" +#import "HelperTools.h" + +@interface MLXMPPIdentity () + +@property (atomic) NSString* user; +@property (atomic) NSString* password; +@property (atomic) NSString* domain; + +@end + +@implementation MLXMPPIdentity + +-(id) initWithJid:(NSString*) jid password:(NSString*) password andResource:(NSString*) resource +{ + self = [super init]; + NSDictionary* parts = [HelperTools splitJid:jid]; + self.jid = parts[@"user"]; + self.resource = resource; + _fullJid = resource ? [NSString stringWithFormat:@"%@/%@", self.jid, self.resource] : jid; + self.password = password; + self.user = parts[@"node"]; + self.domain = parts[@"host"]; + return self; +} + +-(void) updatPassword:(NSString*) newPassword +{ + self.password = newPassword; +} + +-(void) bindJid:(NSString*) jid +{ + NSDictionary* parts = [HelperTools splitJid:jid]; + + //we don't allow this because several parts in monal rely on stable bare jids not changing after login/bind + MLAssert([self.jid isEqualToString:parts[@"user"]], @"trying to bind to different bare jid!", (@{ + @"bind_to_jid": jid, + @"current_bare_jid": self.jid + })); + + //don't set new full jid if we don't have a resource + if(parts[@"resource"] != nil) + { + //these won't change because of the MLAssert above, but we keep this + //to make sure user and domain match the jid once the assertion gets removed + self.jid = parts[@"user"]; + self.user = parts[@"node"]; + self.domain = parts[@"host"]; + + self.resource = parts[@"resource"]; + _fullJid = [NSString stringWithFormat:@"%@/%@", self.jid, self.resource]; + } +} + +@end diff --git a/Monal/Classes/MLXMPPManager.h b/Monal/Classes/MLXMPPManager.h new file mode 100644 index 0000000..ab5df89 --- /dev/null +++ b/Monal/Classes/MLXMPPManager.h @@ -0,0 +1,151 @@ +// +// MLXMPPManager.h +// Monal +// +// Created by Anurodh Pokharel on 6/27/13. +// +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class xmpp; +@class MLContact; + +/** + A singleton to control all of the active XMPP connections + */ +@interface MLXMPPManager : NSObject +{ + dispatch_source_t _pinger; +} + ++(MLXMPPManager*) sharedInstance; + +-(BOOL) allAccountsIdle; + +#pragma mark connectivity +/** + Checks if there are any enabled acconts and connects them if necessary. + */ +-(void) connectIfNecessary; + +/** + logout all accounts + */ +-(void) reconnectAll; + +-(void) disconnectAll; + +/** + disconnects the specified account + */ +-(void) disconnectAccount:(NSNumber*) accountID withExplicitLogout:(BOOL) explicitLogout; + +/** + connects the specified account + */ +-(void) connectAccount:(NSNumber*) accountID; + +#pragma mark XMPP commands +/** + Remove a contact from an account + */ +-(void) removeContact:(MLContact*) contact; + +/** + Add a contact from an account + */ +-(void) addContact:(MLContact*) contact; +-(void) addContact:(MLContact*) contact withPreauthToken:(NSString* _Nullable) preauthToken; + +/** + Block a jid + */ +-(void) block:(BOOL) isBlocked contact:(MLContact*) contact; +-(void) block:(BOOL) isBlocked fullJid:(NSString*) contact onAccount:(NSNumber*) accountID; + +/** + Returns the user set name of the conencted account + */ +-(NSString*) getAccountNameForConnectedRow:(NSUInteger) row; + +/* + gets the connected account apecified by id. return nil otherwise + */ +-(xmpp* _Nullable) getEnabledAccountForID:(NSNumber*) accountID; + +/** + Returns YES if account is connected + */ +-(BOOL) isAccountForIdConnected:(NSNumber*) accountID; + +/** + When the account estblihsed its current connection. + */ +-(NSDate *) connectedTimeFor:(NSNumber*) accountID; + +-(NSNumber* _Nullable) login:(NSString*) jid password:(NSString*) password; +-(NSNumber* _Nullable) login:(NSString*) jid password:(NSString*) password hardcodedServer:(NSString* _Nullable) hardcodedServer hardcodedPort:(NSString* _Nullable) hardcodedPort forceDirectTLS:(BOOL) directTLS allowPlainAuth:(BOOL) plainActivated; +-(void) removeAccountForAccountID:(NSNumber*) accountID; +-(void) addNewAccountToKeychainAndConnectWithPassword:(NSString*) password andAccountID:(NSNumber*) accountID; + +/** + update the password in the keychan and update memory cache + */ +-(BOOL) isValidPassword:(NSString*) password forAccount:(NSNumber*) accountID; +-(NSString*) getPasswordForAccount:(NSNumber*) accountID; +-(void) updatePassword:(NSString*) password forAccount:(NSNumber*) accountID; + +/** +Sends a message to a specified contact in account. Calls completion handler on success or failure. + */ +-(void) sendMessageAndAddToHistory:(NSString*) message havingType:(NSString*) messageType toContact:(MLContact*) contact isEncrypted:(BOOL) encrypted uploadInfo:(NSDictionary* _Nullable) uploadInfo withCompletionHandler:(void (^ _Nullable)(BOOL success, NSString* messageId)) completion; +-(void)sendMessage:(NSString*) message toContact:(MLContact*) contact isEncrypted:(BOOL) encrypted isUpload:(BOOL) isUpload messageId:(NSString*) messageId withCompletionHandler:(void (^ _Nullable)(BOOL success, NSString* messageId)) completion; +-(void) sendChatState:(BOOL) isTyping toContact:(MLContact*) contact; + +#pragma mark XMPP settings + +@property (nonatomic, strong, readonly) NSMutableArray* connectedXMPP; +@property (nonatomic, readonly) BOOL hasConnectivity; + +@property (nonatomic, assign) BOOL hasAPNSToken; +@property (nonatomic, strong) NSString* pushToken; +@property (nonatomic, strong) NSError* _Nullable apnsError; + +@property (nonatomic, readonly) BOOL isBackgrounded; +@property (nonatomic, readonly) BOOL isNotInFocus; + +@property (nonatomic, readonly) BOOL onMobile; + +@property (nonatomic, assign) BOOL isConnectBlocked; + +/** + updates delivery status after message has been sent + */ +-(void) handleSentMessage:(NSNotification*) notification; + +-(void) noLongerInFocus; + +/** + updates client state on server as inactive + */ +-(void) nowBackgrounded; + +/** + sets client state on server as active + */ +-(void) nowForegrounded; + +/** + fetch entity software version + */ +-(void) getEntitySoftWareVersionForContact:(MLContact*) contact andResource:(NSString*) resource; + +-(void) setPushToken:(NSString* _Nullable) token; +-(void) removeToken; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m new file mode 100644 index 0000000..2083a2f --- /dev/null +++ b/Monal/Classes/MLXMPPManager.m @@ -0,0 +1,967 @@ +// +// MLXMPPManager.m +// Monal +// +// Created by Anurodh Pokharel on 6/27/13. +// +// + +#import + +#import "MLConstants.h" +#import "MLXMPPManager.h" +#import "DataLayer.h" +#import "HelperTools.h" +#import "xmpp.h" +#import "XMPPMessage.h" +#import "MLNotificationQueue.h" +#import "MLNotificationManager.h" +#import "MLOMEMO.h" +#import + +@import Network; +@import MobileCoreServices; +@import SAMKeychain; +@import Intents; + +static const int pingFreqencyMinutes = 5; //about the same Conversations uses +#define FIRST_LOGIN_TIMEOUT 30.0 + +@interface MLXMPPManager() +{ + nw_path_monitor_t _path_monitor; + BOOL _hasConnectivity; + NSMutableArray* _connectedXMPP; +} +@end + +@implementation MLXMPPManager + +-(void) defaultSettings +{ + [self upgradeBoolUserSettingsIfUnset:@"Sound" toDefault:YES]; + [self upgradeObjectUserSettingsIfUnset:@"AlertSoundFile" toDefault:@"alert2"]; + + // upgrade ShowGeoLocation + [self upgradeBoolUserSettingsIfUnset:@"ShowGeoLocation" toDefault:YES]; + + // upgrade SendLastUserInteraction + [self upgradeBoolUserSettingsIfUnset:@"SendLastUserInteraction" toDefault:YES]; + + // upgrade SendLastChatState + [self upgradeBoolUserSettingsIfUnset:@"SendLastChatState" toDefault:YES]; + + // upgrade received and displayed markers + [self upgradeBoolUserSettingsIfUnset:@"SendReceivedMarkers" toDefault:YES]; + [self upgradeBoolUserSettingsIfUnset:@"SendDisplayedMarkers" toDefault:YES]; + + //upgrade url preview + [self upgradeBoolUserSettingsIfUnset:@"ShowURLPreview" toDefault:YES]; + + //upgrade message autodeletion and migrate old "3 days" setting + NSNumber* oldAutodelete = [[HelperTools defaultsDB] objectForKey:@"AutodeleteAllMessagesAfter3Days"]; + if(oldAutodelete != nil && [oldAutodelete boolValue]) + { + [self upgradeIntegerUserSettingsIfUnset:@"AutodeleteInterval" toDefault:259200]; + [self removeObjectUserSettingsIfSet:@"AutodeleteAllMessagesAfter3Days"]; + } + else + [self upgradeIntegerUserSettingsIfUnset:@"AutodeleteInterval" toDefault:0]; + + //upgrade default omemo on + [self upgradeBoolUserSettingsIfUnset:@"OMEMODefaultOn" toDefault:YES]; + + // upgrade udp logger + [self upgradeBoolUserSettingsIfUnset:@"udpLoggerEnabled" toDefault:NO]; + [self upgradeObjectUserSettingsIfUnset:@"udpLoggerHostname" toDefault:@""]; + [self upgradeObjectUserSettingsIfUnset:@"udpLoggerPort" toDefault:@""]; + [self upgradeObjectUserSettingsIfUnset:@"udpLoggerKey" toDefault:@""]; + + // upgrade Message Settings / Privacy + [self upgradeIntegerUserSettingsIfUnset:@"NotificationPrivacySetting" toDefault:NotificationPrivacySettingOptionDisplayNameAndMessage]; + + // upgrade filetransfer settings + [self upgradeBoolUserSettingsIfUnset:@"AutodownloadFiletransfers" toDefault:YES]; + + //upgrade syncErrorsDisplayed list + [self upgradeObjectUserSettingsIfUnset:@"syncErrorsDisplayed" toDefault:@{}]; + + [self upgradeFloatUserSettingsToInteger:@"AutodownloadFiletransfersMobileMaxSize"]; + [self upgradeFloatUserSettingsToInteger:@"AutodownloadFiletransfersWifiMaxSize"]; + [self upgradeIntegerUserSettingsIfUnset:@"AutodownloadFiletransfersMobileMaxSize" toDefault:5*1024*1024]; // 5 MiB + [self upgradeIntegerUserSettingsIfUnset:@"AutodownloadFiletransfersWifiMaxSize" toDefault:32*1024*1024]; // 32 MiB + + // upgrade default image quality + [self upgradeFloatUserSettingsIfUnset:@"ImageUploadQuality" toDefault:0.50]; + + // remove old settings from shareSheet outbox + [self removeObjectUserSettingsIfSet:@"lastRecipient"]; + [self removeObjectUserSettingsIfSet:@"lastAccount"]; + // remove HasSeenIntro bool + [self removeObjectUserSettingsIfSet:@"HasSeenIntro"]; + + // add default pushserver + [self upgradeObjectUserSettingsIfUnset:@"selectedPushServer" toDefault:[HelperTools getSelectedPushServerBasedOnLocale]]; + + //upgrade background image settings + NSString* bgImage = [[HelperTools defaultsDB] objectForKey:@"BackgroundImage"]; + //image was selected, but it was no custom image --> remove it + if(bgImage != nil && [@"CUSTOM" isEqualToString:bgImage]) + [self removeObjectUserSettingsIfSet:@"BackgroundImage"]; + [self removeObjectUserSettingsIfSet:@"ChatBackgrounds"]; + + // add STUN / TURN settings + [self upgradeBoolUserSettingsIfUnset:@"webrtcAllowP2P" toDefault:YES]; +#ifdef IS_QUICKSY + [self upgradeBoolUserSettingsIfUnset:@"webrtcUseFallbackTurn" toDefault:NO]; +#else + [self upgradeBoolUserSettingsIfUnset:@"webrtcUseFallbackTurn" toDefault:YES]; +#endif + + //jabber:iq:version + [self upgradeBoolUserSettingsIfUnset:@"allowVersionIQ" toDefault:YES]; + + //default value for sanbox is no (e.g. production) + [self upgradeBoolUserSettingsIfUnset:@"isSandboxAPNS" toDefault:NO]; + + //anti spam/privacy setting, but default to yes (current behavior, conversations behavior etc.) + [self upgradeBoolUserSettingsIfUnset:@"allowNonRosterContacts" toDefault:YES]; + [self upgradeBoolUserSettingsIfUnset:@"allowCallsFromNonRosterContacts" toDefault:YES]; + + //mac catalyst will not show a soft-keyboard when setting focus, ios will + //--> only automatically set focus on macos and make this configurable +#if TARGET_OS_MACCATALYST + [self upgradeBoolUserSettingsIfUnset:@"showKeyboardOnChatOpen" toDefault:YES]; +#else + [self upgradeBoolUserSettingsIfUnset:@"showKeyboardOnChatOpen" toDefault:NO]; +#endif + +#ifdef IS_ALPHA + [self upgradeBoolUserSettingsIfUnset:@"useDnssecForAllConnections" toDefault:YES]; +#else + [self upgradeBoolUserSettingsIfUnset:@"useDnssecForAllConnections" toDefault:NO]; +#endif + + NSTimeZone* timeZone = [NSTimeZone localTimeZone]; + DDLogVerbose(@"Current timezone name: '%@'...", [timeZone name]); + if([[timeZone name] containsString:@"Europe"]) + [self upgradeBoolUserSettingsIfUnset:@"useInlineSafari" toDefault:NO]; + else + [self upgradeBoolUserSettingsIfUnset:@"useInlineSafari" toDefault:YES]; + + [self upgradeBoolUserSettingsIfUnset:@"hasCompletedOnboarding" toDefault:NO]; + + [self upgradeBoolUserSettingsIfUnset:@"uploadImagesOriginal" toDefault:NO]; + + [self upgradeBoolUserSettingsIfUnset:@"hardlinkFiletransfersIntoDocuments" toDefault:YES]; + + [self upgradeBoolUserSettingsIfUnset:@"showAdvancedUI" toDefault:NO]; + +// //always show onboarding on simulator for now +// #if TARGET_OS_SIMULATOR +// [[HelperTools defaultsDB] setBool:NO forKey:@"hasCompletedOnboarding"]; +// #endif +} + +-(void) upgradeFloatUserSettingsToInteger:(NSString*) settingsName +{ + if([[HelperTools defaultsDB] objectForKey:settingsName] == nil) + return; + NSInteger value = (NSInteger)[[HelperTools defaultsDB] floatForKey:settingsName]; + [[HelperTools defaultsDB] setInteger:value forKey:settingsName]; + [[HelperTools defaultsDB] synchronize]; +} + +-(void) upgradeBoolUserSettingsIfUnset:(NSString*) settingsName toDefault:(BOOL) defaultVal +{ + NSNumber* currentSettingVal = [[HelperTools defaultsDB] objectForKey:settingsName]; + if(currentSettingVal == nil) + { + [[HelperTools defaultsDB] setBool:defaultVal forKey:settingsName]; + [[HelperTools defaultsDB] synchronize]; + } +} + +-(void) upgradeIntegerUserSettingsIfUnset:(NSString*) settingsName toDefault:(NSInteger) defaultVal +{ + NSNumber* currentSettingVal = [[HelperTools defaultsDB] objectForKey:settingsName]; + if(currentSettingVal == nil) + { + [[HelperTools defaultsDB] setInteger:defaultVal forKey:settingsName]; + [[HelperTools defaultsDB] synchronize]; + } +} + +-(void) upgradeFloatUserSettingsIfUnset:(NSString*) settingsName toDefault:(float) defaultVal +{ + NSNumber* currentSettingVal = [[HelperTools defaultsDB] objectForKey:settingsName]; + if(currentSettingVal == nil) + { + [[HelperTools defaultsDB] setFloat:defaultVal forKey:settingsName]; + [[HelperTools defaultsDB] synchronize]; + } +} + +-(void) upgradeObjectUserSettingsIfUnset:(NSString*) settingsName toDefault:(nullable id) defaultVal +{ + NSNumber* currentSettingVal = [[HelperTools defaultsDB] objectForKey:settingsName]; + if(currentSettingVal == nil) + { + [[HelperTools defaultsDB] setObject:defaultVal forKey:settingsName]; + [[HelperTools defaultsDB] synchronize]; + } +} + +-(void) removeObjectUserSettingsIfSet:(NSString*) settingsName +{ + NSObject* currentSettingsVal = [[HelperTools defaultsDB] objectForKey:settingsName]; + if(currentSettingsVal != nil) + { + DDLogInfo(@"Removing defaultsDB Entry %@", settingsName); + [[HelperTools defaultsDB] removeObjectForKey:settingsName]; + [[HelperTools defaultsDB] synchronize]; + } +} + ++(MLXMPPManager*) sharedInstance +{ + static dispatch_once_t once; + static MLXMPPManager* sharedInstance; + dispatch_once(&once, ^{ + sharedInstance = [MLXMPPManager new] ; + }); + return sharedInstance; +} + +-(id) init +{ + self = [super init]; + + _connectedXMPP = [NSMutableArray new]; + _hasConnectivity = NO; + _isBackgrounded = NO; + _isNotInFocus = NO; + _onMobile = NO; + _isConnectBlocked = NO; + + [self defaultSettings]; + [self setPushToken:nil]; //load push settings from defaultsDB (can be overwritten later on in mainapp, but *not* in appex) + + //set up regular ping + dispatch_queue_t q_background = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0); + _pinger = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, q_background); + + dispatch_source_set_timer(_pinger, + DISPATCH_TIME_NOW, + 60ull * NSEC_PER_SEC * pingFreqencyMinutes, + 60ull * NSEC_PER_SEC); //allow for better battery optimizations + + dispatch_source_set_event_handler(_pinger, ^{ + for(xmpp* xmppAccount in [self connectedXMPP]) + { + if(xmppAccount.accountState>=kStateBound) { + DDLogInfo(@"began a idle ping"); + [xmppAccount sendPing:LONG_PING]; //long ping timeout because this is a background/interval ping + } + } + }); + + dispatch_source_set_cancel_handler(_pinger, ^{ + DDLogInfo(@"pinger canceled"); + }); + + dispatch_resume(_pinger); + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleSentMessage:) name:kMonalSentMessageNotice object:nil]; + + //this processes the sharesheet outbox only, the handler in the NotificationServiceExtension will do more interesting things + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(catchupFinished:) name:kMonalFinishedCatchup object:nil]; + + _path_monitor = nw_path_monitor_create(); + nw_path_monitor_set_queue(_path_monitor, q_background); + nw_path_monitor_set_update_handler(_path_monitor, ^(nw_path_t path) { + DDLogVerbose(@"*** nw_path_monitor: update_handler called"); + DDLogDebug(@"*** nw_path_monitor: nw_path_is_constrained=%@", bool2str(nw_path_is_constrained(path))); + DDLogDebug(@"*** nw_path_monitor: nw_path_is_expensive=%@", bool2str(nw_path_is_expensive(path))); + self->_onMobile = nw_path_is_constrained(path) || nw_path_is_expensive(path); + DDLogDebug(@"*** nw_path_monitor: on 'mobile' --> %@", bool2str(self->_onMobile)); + if(nw_path_get_status(path) == nw_path_status_satisfied && !self->_hasConnectivity) + { + DDLogVerbose(@"reachable again"); + self->_hasConnectivity = YES; + for(xmpp* xmppAccount in [self connectedXMPP]) + { + if(![HelperTools isAppExtension]) + { + //try to send a ping. if it fails, it will reconnect + DDLogVerbose(@"manager pinging"); + [xmppAccount sendPing:SHORT_PING]; //short ping timeout to quickly check if connectivity is still okay + } + else + { + //don't reconnect if appex has frozen our queues! + if(!xmppAccount.parseQueueFrozen) + [xmppAccount reconnect:0]; //try to immediately reconnect, don't bother pinging + else + DDLogDebug(@"Not trying to reconnect in 0s, parse queue frozen!"); + } + } + + [[MLNotificationQueue currentQueue] postNotificationName:kMonalConnectivityChange object:self userInfo:@{@"reachable": @YES}]; + } + else if(nw_path_get_status(path) != nw_path_status_satisfied && self->_hasConnectivity) + { + DDLogVerbose(@"NOT reachable"); + self->_hasConnectivity = NO; + + DDLogVerbose(@"scheduling background fetching task to start app in background once our connectivity gets restored"); + //this will automatically start the app if connectivity gets restored + //always force as soon as possible to make sure any missed pushes get compensated for + //don't queue this notification because it should be handled immediately + [[NSNotificationCenter defaultCenter] postNotificationName:kScheduleBackgroundTask object:nil userInfo:@{@"force": @YES}]; + + [[MLNotificationQueue currentQueue] postNotificationName:kMonalConnectivityChange object:self userInfo:@{@"reachable": @NO}]; + } + else if(nw_path_get_status(path) == nw_path_status_satisfied) + { + DDLogVerbose(@"still reachable"); + //when switching from wifi to mobile (or back) we sometimes don't have any unreachable state in between + //--> reconnect directly because switching from wifi to mobile will cut the connection a few seconds after the switch anyways + //NOTE: wait for 1 sec before reconnecting to compensate for multiple nw_path updates in a row + for(xmpp* xmppAccount in [self connectedXMPP]) + //don't reconnect if appex has frozen our queues! + if(!xmppAccount.parseQueueFrozen) + { + [NSThread sleepForTimeInterval:1]; + [xmppAccount sendPing:SHORT_PING]; //short ping timeout to quickly check if connectivity is still okay + } + else + DDLogDebug(@"Not pinging after 1s, parse queue frozen!"); + + [[MLNotificationQueue currentQueue] postNotificationName:kMonalConnectivityChange object:self userInfo:@{@"reachable": @YES}]; + } + else + DDLogVerbose(@"nothing changed, still NOT reachable"); + }); + nw_path_monitor_start(_path_monitor); + + //trigger iq invalidations and idle timers from a background thread because timeouts aren't time critical + //we use this to decrement the timeout value of an iq handler / idle timer every second until it reaches zero + dispatch_async(dispatch_queue_create_with_target("im.monal.timeouts", DISPATCH_QUEUE_SERIAL, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0)), ^{ + while(YES) { + for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP) + [account updateIqHandlerTimeouts]; + + //needed to not crash the app with an obscure EXC_BREAKPOINT while deleting something in a currently open chat + //the crash report then contains: message at /usr/lib/system/libdispatch.dylib: API MISUSE: Resurrection of an object + //(triggered by [HelperTools dispatchAsync:reentrantOnQueue:withBlock:] in it's call to dispatch_get_current_queue()) + dispatch_async(dispatch_get_main_queue(), ^{ + NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; + if(autodeleteInterval > 0) + { + NSNumber* deletionCount = [[DataLayer sharedInstance] autoDeleteMessagesAfterInterval:(NSTimeInterval)autodeleteInterval]; + //make sure our ui updates after a deletion + if(deletionCount.integerValue > 0) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + } + }); + + [NSThread sleepForTimeInterval:1]; + } + }); + + return self; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + if(_pinger) + dispatch_source_cancel(_pinger); +} + +//this returns a copy to iterate on without the need of a synchronized block while iterating +-(NSArray*) connectedXMPP +{ + @synchronized(_connectedXMPP) { + return [[NSArray alloc] initWithArray:_connectedXMPP]; + } +} + +-(void) catchupFinished:(NSNotification*) notification +{ + xmpp* account = notification.object; + DDLogInfo(@"### MAM/SMACKS CATCHUP FINISHED FOR ACCOUNT NO %@ ###", account.accountID); +} + +-(BOOL) allAccountsIdle +{ + for(xmpp* xmppAccount in [self connectedXMPP]) + if(!xmppAccount.idle) + return NO; + return YES; +} + +#pragma mark - app state + +-(void) noLongerInFocus +{ + _isBackgrounded = NO; + _isNotInFocus = YES; +} + +-(void) nowBackgrounded +{ + DDLogInfo(@"App now backgrounded..."); + + _isBackgrounded = YES; + _isNotInFocus = YES; + + for(xmpp* xmppAccount in [self connectedXMPP]) + [xmppAccount setClientInactive]; +} + +-(void) nowForegrounded +{ + DDLogInfo(@"App now foregrounded..."); + + _isBackgrounded = NO; + _isNotInFocus = NO; + + //*** we don't need to check for a running service extension here because the appdelegate does this already for us *** + + for(xmpp* xmppAccount in [self connectedXMPP]) + { + [xmppAccount unfreeze]; + [xmppAccount sendPing:SHORT_PING]; //short ping timeout to quickly check if connectivity is still okay + [xmppAccount setClientActive]; + } + + //we are in foreground now (or at least we have been for a few seconds) + //--> clear sync error notifications so that they can appear again + //wait some time to make sure all xmpp class instances have been created + createTimer(1, (^{ + [HelperTools clearSyncErrorsOnAppForeground]; + })); +} + +#pragma mark - Connection related + +-(BOOL) isAccountForIdConnected:(NSNumber*) accountID +{ + xmpp* account = [self getEnabledAccountForID:accountID]; + if(account.accountState>=kStateBound) return YES; + return NO; +} + +-(NSDate *) connectedTimeFor:(NSNumber*) accountID +{ + xmpp* account = [self getEnabledAccountForID:accountID]; + return account.connectedTime; +} + +-(xmpp* _Nullable) getEnabledAccountForID:(NSNumber*) accountID +{ + for(xmpp* xmppAccount in [self connectedXMPP]) + { + //using stringWithFormat: makes sure this REALLY is a string + if(xmppAccount.accountID.intValue == accountID.intValue) + return xmppAccount; + } + return nil; +} + +-(void) connectAccount:(NSNumber*) accountID +{ + NSDictionary* account = [[DataLayer sharedInstance] detailsForAccount:accountID]; + if(!account) + DDLogError(@"Expected account settings in db for accountID: %@", accountID); + else + [self connectAccountWithDictionary:account]; +} + +-(void) connectAccountWithDictionary:(NSDictionary*) account +{ + xmpp* existing = [self getEnabledAccountForID:[account objectForKey:kAccountID]]; + if(existing) + { + if(![account[@"enabled"] boolValue]) + { + DDLogInfo(@"existing but disabled account, ignoring"); + return; + } + if(_isConnectBlocked) + { + DDLogWarn(@"connect blocked, ignoring"); + return; + } + DDLogInfo(@"existing account, calling unfreeze"); + [existing unfreeze]; + DDLogInfo(@"existing account, just pinging."); + [existing sendPing:SHORT_PING]; //short ping timeout to quickly check if connectivity is still okay + return; + } + DDLogVerbose(@"connecting account %@@%@",[account objectForKey:kUsername], [account objectForKey:kDomain]); + + NSError* error; + NSString* jid = [NSString stringWithFormat:@"%@@%@", account[kUsername], account[kDomain]]; + NSString* password = [SAMKeychain passwordForService:kMonalKeychainName account:((NSNumber*)account[kAccountID]).stringValue error:&error]; + if(error) + { + DDLogError(@"Keychain error: %@", error); + + // Disable account because login will not be possible + [[DataLayer sharedInstance] disableAccountForPasswordMigration:account[kAccountID]]; + [self disconnectAccount:account[kAccountID] withExplicitLogout:YES]; + + //show notifications for disabled accounts to warn user if in appex + if([HelperTools isAppExtension]) + { + UNMutableNotificationContent* content = [UNMutableNotificationContent new]; + content.title = NSLocalizedString(@"Account disabled", @"");; + content.subtitle = jid; + content.body = NSLocalizedString(@"You restored an iCloud backup of Monal, please open the app to reenable this account.", @""); + content.sound = [UNNotificationSound defaultSound]; + content.categoryIdentifier = @"simple"; + UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:[NSString stringWithFormat:@"disabled::%@", jid] content:content trigger:nil]; + error = [HelperTools postUserNotificationRequest:request]; + if(error) + DDLogError(@"Error posting account disabled notification: %@", error); + } + + return; + } + MLXMPPIdentity* identity = [[MLXMPPIdentity alloc] initWithJid:jid password:password andResource:[account objectForKey:kResource]]; + MLXMPPServer* server = [[MLXMPPServer alloc] initWithHost:[account objectForKey:kServer] andPort:[account objectForKey:kPort] andDirectTLS:[[account objectForKey:kDirectTLS] boolValue]]; + xmpp* xmppAccount = [[xmpp alloc] initWithServer:server andIdentity:identity andAccountID:[account objectForKey:kAccountID]]; + xmppAccount.statusMessage = [account objectForKey:@"statusMessage"]; + + @synchronized(_connectedXMPP) { + [_connectedXMPP addObject:xmppAccount]; + } + + if(![account[@"enabled"] boolValue]) + { + DDLogInfo(@"existing but disabled account, not connecting"); + return; + } + if(!self.isConnectBlocked) + { + DDLogInfo(@"starting connect"); + [xmppAccount connect]; + } + else + DDLogWarn(@"connect blocked, not connecting newly created xmpp* instance"); +} + +-(void) disconnectAccount:(NSNumber*) accountID withExplicitLogout:(BOOL) explicitLogout +{ + int index = 0; + int pos = -1; + xmpp* account; + @synchronized(_connectedXMPP) { + for(xmpp* xmppAccount in _connectedXMPP) + { + if(xmppAccount.accountID.intValue == accountID.intValue) + { + account = xmppAccount; + pos=index; + break; + } + index++; + } + + if((pos >= 0) && (pos < (int)[_connectedXMPP count])) + { + [_connectedXMPP removeObjectAtIndex:pos]; + DDLogVerbose(@"removed account at pos %d", pos); + } + } + if(account) + { + DDLogVerbose(@"got account and cleaning up.. "); + [account disconnect:explicitLogout]; + account = nil; + DDLogVerbose(@"done cleaning up account "); + } +} + + +-(void) reconnectAll +{ + NSArray* allAccounts = [[DataLayer sharedInstance] accountList]; //this will also "disconnect" disabled account, just to make sure + for(NSDictionary* account in allAccounts) + { + DDLogVerbose(@"Forcefully disconnecting account %@ (%@@%@)", [account objectForKey:kAccountID], [account objectForKey:@"username"], [account objectForKey:@"domain"]); + xmpp* xmppAccount = [self getEnabledAccountForID:[account objectForKey:kAccountID]]; + if(xmppAccount != nil) + [xmppAccount disconnect:YES]; + } + createTimer(2.0, (^{ + [self connectIfNecessary]; + })); +} + +-(void) disconnectAll +{ + DDLogVerbose(@"manager disconnecAll"); + dispatch_queue_t queue = dispatch_queue_create("im.monal.disconnect", DISPATCH_QUEUE_CONCURRENT); + for(xmpp* xmppAccount in [self connectedXMPP]) + { + //disconnect to prevent endless loops trying to connect + dispatch_async(queue, ^{ + DDLogVerbose(@"manager disconnecting: %@", xmppAccount.accountID); + [xmppAccount disconnect]; + DDLogVerbose(@"manager disconnected: %@", xmppAccount.accountID); + }); + } + dispatch_barrier_sync(queue, ^{ + DDLogVerbose(@"manager disconnecAll done (inside barrier)"); + }); + DDLogVerbose(@"manager disconnecAll done"); +} + +-(void) connectIfNecessary +{ + DDLogVerbose(@"manager connectIfNecessary"); + NSArray* enabledAccountList = [[DataLayer sharedInstance] enabledAccountList]; + for(NSDictionary* account in enabledAccountList) + [self connectAccountWithDictionary:account]; + DDLogVerbose(@"manager connectIfNecessary done"); +} + +-(void) updatePassword:(NSString*) password forAccount:(NSNumber*) accountID +{ + [SAMKeychain setAccessibilityType:kSecAttrAccessibleAfterFirstUnlock]; + [SAMKeychain setPassword:password forService:kMonalKeychainName account:accountID.stringValue]; + xmpp* xmpp = [self getEnabledAccountForID:accountID]; + [xmpp.connectionProperties.identity updatPassword:password]; +} + +-(BOOL) isValidPassword:(NSString*) password forAccount:(NSNumber*) accountID +{ + return [password isEqualToString:[SAMKeychain passwordForService:kMonalKeychainName account:accountID.stringValue]]; +} + +//this is only used by quicksy +-(NSString*) getPasswordForAccount:(NSNumber*) accountID +{ + return [SAMKeychain passwordForService:kMonalKeychainName account:accountID.stringValue]; +} + +#pragma mark - XMPP commands +-(void) sendMessageAndAddToHistory:(NSString*) message havingType:(NSString*) messageType toContact:(MLContact*) contact isEncrypted:(BOOL) encrypted uploadInfo:(NSDictionary* _Nullable) uploadInfo withCompletionHandler:(void (^ _Nullable)(BOOL success, NSString* messageId)) completion +{ + NSString* msgid = [[NSUUID UUID] UUIDString]; + xmpp* account = contact.account; + + MLAssert(message != nil, @"Message should not be nil"); + MLAssert(account != nil, @"Account should not be nil"); + MLAssert(contact != nil, @"Contact should not be nil"); + MLAssert(uploadInfo == nil || messageType == kMessageTypeFiletransfer, @"You must use message type = filetransfer if you supply an uploadInfo!"); + + // Save message to history + NSNumber* messageDBId = [[DataLayer sharedInstance] + addMessageHistoryTo:contact.contactJid + forAccount:contact.accountID + withMessage:message + actuallyFrom:(contact.isMuc ? contact.accountNickInGroup : account.connectionProperties.identity.jid) + withId:msgid + encrypted:encrypted + messageType:messageType + mimeType:uploadInfo[@"mimeType"] + size:uploadInfo[@"size"] + ]; + // Send message + if(messageDBId != nil) + { + DDLogInfo(@"Message added to history with id %ld, now sending...", (long)[messageDBId intValue]); + [self sendMessage:message toContact:contact isEncrypted:encrypted isUpload:(uploadInfo != nil) messageId:msgid withCompletionHandler:^(BOOL successSend, NSString* messageIdSend) { + completion(successSend, messageIdSend); + }]; + DDLogVerbose(@"Notifying active chats of change for contact %@", contact); + [[MLNotificationQueue currentQueue] postNotificationName:kMLMessageSentToContact object:self userInfo:@{@"contact":contact}]; + + //create and donate interaction to allow for share suggestions + [[MLNotificationManager sharedInstance] donateInteractionForOutgoingDBId:messageDBId]; + } + else + { + DDLogError(@"Could not add message to history!"); + completion(false, nil); + } +} + +-(void) sendMessage:(NSString*) message toContact:(MLContact*) contact isEncrypted:(BOOL) encrypted isUpload:(BOOL) isUpload messageId:(NSString*) messageId withCompletionHandler:(void (^ _Nullable)(BOOL success, NSString* messageId)) completion +{ + BOOL success = NO; + xmpp* account = contact.account; + if(account) + { + success = YES; + [account sendMessage:message toContact:contact isEncrypted:encrypted isUpload:isUpload andMessageId:messageId]; + } + if(completion) + completion(success, messageId); +} + +-(void) sendChatState:(BOOL) isTyping toContact:(MLContact*) contact +{ + xmpp* account = contact.account; + if(account) + [account sendChatState:isTyping toContact:contact]; +} + +#pragma mark - login/register + +-(NSNumber*) login:(NSString*) jid password:(NSString*) password +{ + NSArray* elements = [jid componentsSeparatedByString:@"@"]; + MLAssert([elements count] > 1, @"Got invalid jid", (@{@"jid": nilWrapper(jid), @"elements": elements})); + NSString* domain = ((NSString*)[elements objectAtIndex:1]).lowercaseString; + + //we don't want to set kPlainActivated (not even according to our preload list) and default to plain_activated=false, + //because the error message will warn the user and direct them to the advanced account creation menu to activate PLAIN + //if they still want to connect to this server + //only exception: yax.im --> we don't want to suggest a server during account creation that has a scary warning + //when logging in using another device afterwards + //TODO: to be removed once yax.im and quicksy.im supports SASL2 and SSDP!! + //TODO: use preload list and allow PLAIN for all others once enough domains are on this list + //allow plain for all servers not on preload list, since prosody with SASL2 wasn't even released yet + BOOL defaultPlainActivated = YES; + BOOL plainActivated = ([domain isEqualToString:@"yax.im"] || [domain isEqualToString:@"quicksy.im"]) ? YES : defaultPlainActivated; + + return [self login:jid password:password hardcodedServer:nil hardcodedPort:nil forceDirectTLS:NO allowPlainAuth:plainActivated]; +} + +-(NSNumber*) login:(NSString*) jid password:(NSString*) password hardcodedServer:(NSString*) hardcodedServer hardcodedPort:(NSString*) hardcodedPort forceDirectTLS:(BOOL) directTLS allowPlainAuth:(BOOL) plainActivated +{ + //check if it is a JID + NSArray* elements = [jid componentsSeparatedByString:@"@"]; + MLAssert([elements count] > 1, @"Got invalid jid", (@{@"jid": nilWrapper(jid), @"elements": elements})); + + NSString* domain; + NSString* user; + user = ((NSString*)[elements objectAtIndex:0]).lowercaseString; + domain = ((NSString*)[elements objectAtIndex:1]).lowercaseString; + + if([[DataLayer sharedInstance] doesAccountExistUser:user andDomain:domain]) + { + [[MLNotificationQueue currentQueue] postNotificationName:kXMPPError object:nil userInfo:@{ + @"title": NSLocalizedString(@"Duplicate Account", @""), + @"description": NSLocalizedString(@"This account already exists on this instance", @"") + }]; + return nil; + } + + NSMutableDictionary* dic = [NSMutableDictionary new]; + [dic setObject:domain forKey:kDomain]; + [dic setObject:user forKey:kUsername]; + [dic setObject:[HelperTools encodeRandomResource] forKey:kResource]; + [dic setObject:@YES forKey:kEnabled]; + if(hardcodedServer != nil) + [dic setObject:hardcodedServer forKey:kServer]; + if(hardcodedPort != nil) + [dic setObject:hardcodedPort forKey:kPort]; + [dic setObject:@(directTLS) forKey:kDirectTLS]; + [dic setObject:@(plainActivated) forKey:kPlainActivated]; + + NSNumber* accountID = [[DataLayer sharedInstance] addAccountWithDictionary:dic]; + if(accountID == nil) + return nil; + [self addNewAccountToKeychainAndConnectWithPassword:password andAccountID:accountID]; + return accountID; +} + +-(void) addNewAccountToKeychainAndConnectWithPassword:(NSString*) password andAccountID:(NSNumber*) accountID +{ + if(accountID != nil && password != nil) + { + [SAMKeychain setAccessibilityType:kSecAttrAccessibleAfterFirstUnlock]; + [SAMKeychain setPassword:password forService:kMonalKeychainName account:accountID.stringValue]; + [self connectAccount:accountID]; + } +} + +-(void) removeAccountForAccountID:(NSNumber*) accountID +{ + [self disconnectAccount:accountID withExplicitLogout:YES]; + [[DataLayer sharedInstance] removeAccount:accountID]; + [SAMKeychain deletePasswordForService:kMonalKeychainName account:accountID.stringValue]; + [HelperTools removeAllShareInteractionsForAccountID:accountID]; + // trigger UI removal + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; +} + +#pragma mark - getting details + +-(NSString*) getAccountNameForConnectedRow:(NSUInteger) row +{ + xmpp* account; + @synchronized(_connectedXMPP) { + if(row<[_connectedXMPP count] && row>=0) + account = [_connectedXMPP objectAtIndex:row]; + } + if(account) + return account.connectionProperties.identity.jid; + return @""; +} + +#pragma mark - contact + +//this handler will simply retry the removeContact: call +$$class_handler(handleRemoveContact, $$ID(MLContact*, contact)) + [[MLXMPPManager sharedInstance] removeContact:contact]; +$$ +-(void) removeContact:(MLContact*) contact +{ + xmpp* account = contact.account; + if(account) + { + //queue remove contact for execution once bound (e.g. on catchup done) + if(account.accountState < kStateBound) + { + [account addReconnectionHandler:$newHandler(self, handleRemoveContact, $ID(contact))]; + return; + } + + if(contact.isMuc) + [account leaveMuc:contact.contactJid]; + else + [account removeFromRoster:contact]; + + //remove from DB + [[DataLayer sharedInstance] removeBuddy:contact.contactJid forAccount:contact.accountID]; + [contact removeShareInteractions]; + + //notify the UI + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRemoved object:account userInfo:@{ + @"contact": [MLContact createContactFromJid:contact.contactJid andAccountID:contact.accountID] + }]; + } +} + +-(void) addContact:(MLContact*) contact +{ + [self addContact:contact withPreauthToken:nil]; +} + +//this handler will simply retry the addContact:withPreauthToken: call +$$class_handler(handleAddContact, $$ID(MLContact*, contact), $_ID(NSString*, preauthToken)) + [[MLXMPPManager sharedInstance] addContact:contact withPreauthToken:preauthToken]; +$$ +-(void) addContact:(MLContact*) contact withPreauthToken:(NSString* _Nullable) preauthToken +{ + xmpp* account = contact.account; + if(account) + { + //queue add contact for execution once bound (e.g. on catchup done) + if(account.accountState < kStateBound) + { + [account addReconnectionHandler:$newHandler(self, handleAddContact, $ID(contact), $ID(preauthToken))]; + return; + } + + if(contact.isMuc) + [account joinMuc:contact.contactJid]; + else + { + [account addToRoster:contact withPreauthToken:preauthToken]; + +#ifndef DISABLE_OMEMO + // Request omemo devicelist + [account.omemo subscribeAndFetchDevicelistIfNoSessionExistsForJid:contact.contactJid]; +#endif// DISABLE_OMEMO + } + + //notify the UI + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self userInfo:@{ + @"contact": [MLContact createContactFromJid:contact.contactJid andAccountID:contact.accountID] + }]; + } +} + +-(void) getEntitySoftWareVersionForContact:(MLContact*) contact andResource:(NSString*) resource +{ + xmpp* account = contact.account; + + NSString* xmppId = @""; + if ((resource == nil) || ([resource length] == 0)) { + xmppId = [NSString stringWithFormat:@"%@",contact.contactJid]; + } else { + xmppId = [NSString stringWithFormat:@"%@/%@",contact.contactJid, resource]; + } + + [account getEntitySoftWareVersion:xmppId]; +} + +-(void) block:(BOOL) isBlocked contact:(MLContact*) contact +{ + DDLogVerbose(@"Blocking %@: %@", contact, bool2str(isBlocked)); + xmpp* account = contact.account; + [account setBlocked:isBlocked forJid:contact.contactJid]; +} + +-(void) block:(BOOL) isBlocked fullJid:(NSString*) fullJid onAccount:(NSNumber*) accountID +{ + DDLogVerbose(@"Blocking %@ on account %@: %@", fullJid, accountID, bool2str(isBlocked)); + xmpp* account = [self getEnabledAccountForID:accountID]; + [account setBlocked:isBlocked forJid:fullJid]; +} + +#pragma mark message signals + +-(void) handleSentMessage:(NSNotification*) notification +{ + XMPPMessage* msg = notification.userInfo[@"message"]; + DDLogInfo(@"message %@, %@ sent, setting status accordingly", msg.id, msg.toUser); + [[DataLayer sharedInstance] setMessageId:msg.id andJid:msg.toUser sent:YES]; +} + +#pragma mark - APNS + +-(void) setPushToken:(NSString* _Nullable) token +{ + if(token && ![token isEqualToString:_pushToken]) + { + _pushToken = token; + [[HelperTools defaultsDB] setObject:_pushToken forKey:@"pushToken"]; + //this will be used by XMPPIQ setPushEnableWithNode and DataLayerMigrations + //save it when the token changes, to keep token and type in sync + [[HelperTools defaultsDB] setBool:[HelperTools isSandboxAPNS] forKey:@"isSandboxAPNS"]; + } + else //use saved one if we are in NSE appex --> we can't get a new token and the old token might still be valid + _pushToken = [[HelperTools defaultsDB] objectForKey:@"pushToken"]; + + //check node and secret values + if( + _pushToken && + _pushToken.length + ) + { + DDLogInfo(@"push token valid, current push settings: token=%@, isSandboxAPNS=%@", _pushToken, [[HelperTools defaultsDB] boolForKey:@"isSandboxAPNS"] ? @"YES" : @"NO"); + self.hasAPNSToken = YES; + } + else + { + self.hasAPNSToken = NO; + DDLogWarn(@"push token invalid, current push settings: token=%@, isSandboxAPNS=%@", _pushToken, [[HelperTools defaultsDB] boolForKey:@"isSandboxAPNS"] ? @"YES" : @"NO"); + } + + //only try to enable push if we have a node and secret value + if(self.hasAPNSToken) + for(xmpp* xmppAccount in [self connectedXMPP]) + [xmppAccount enablePush]; +} + +-(void) removeToken +{ + DDLogWarn(@"APNS removing push token"); + + [[HelperTools defaultsDB] removeObjectForKey:@"pushToken"]; + self.hasAPNSToken = NO; + for(xmpp* xmppAccount in [self connectedXMPP]) + [xmppAccount disablePush]; +} + +@end diff --git a/Monal/Classes/MLXMPPServer.h b/Monal/Classes/MLXMPPServer.h new file mode 100644 index 0000000..414ecc8 --- /dev/null +++ b/Monal/Classes/MLXMPPServer.h @@ -0,0 +1,49 @@ +// +// MLXMPPServer.h +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Class to contain specifics of an XMPP server + */ +@interface MLXMPPServer : NSObject + +@property (nonatomic, readonly) NSString *host; +@property (nonatomic, readonly) NSNumber *port; + +@property (nonatomic,assign) BOOL directTLS; + +-(id) initWithHost:(NSString *) host andPort:(NSNumber *) port andDirectTLS:(BOOL) directTLS; + + +- (void) updateConnectServer:(NSString *) server; + +- (void) updateConnectPort:(NSNumber *) port; + +- (void) updateConnectTLS:(BOOL) isSecure; + +/** + returns the currently connected server may be host or dns one. + */ +- (NSString *) connectServer; + +/** +returns the currently connected port may be configured or dns one. +*/ +- (NSNumber *) connectPort; + +/** + Will indicate whether direct TLS us used. This is either the old style or updated via DNS discovery +*/ +- (BOOL) isDirectTLS; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLXMPPServer.m b/Monal/Classes/MLXMPPServer.m new file mode 100644 index 0000000..a96293d --- /dev/null +++ b/Monal/Classes/MLXMPPServer.m @@ -0,0 +1,71 @@ +// +// MLXMPPServer.m +// Monal +// +// Created by Anurodh Pokharel on 11/27/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "MLXMPPServer.h" + + +@interface MLXMPPServer () + +/** + These are the values that are set by config + */ +@property (nonatomic, strong) NSString *host; +@property (nonatomic, strong) NSNumber *port; + +/** + These may be values that are set by DNS discovery. It may not match + */ +@property (nonatomic, strong) NSString *serverInUse; +@property (nonatomic, strong) NSNumber *portInUse; +@property (nonatomic, assign) BOOL directTLSInUse; + +@end + +@implementation MLXMPPServer + +-(id) initWithHost:(NSString *) host andPort:(NSNumber *) port andDirectTLS:(BOOL) directTLS { + self = [super init]; + self.host=host; + self.port=port; + self.directTLS=directTLS; + + self.serverInUse=host; + self.portInUse=port; + self.directTLSInUse=directTLS; + + return self; +} + +- (void) updateConnectServer:(NSString *) server +{ + self.serverInUse = server; +} + +- (NSString *) connectServer { + return self.serverInUse; +} + +- (void) updateConnectPort:(NSNumber *) port +{ + self.portInUse = port; +} + +- (NSNumber *) connectPort { + return self.portInUse; +} + +- (void) updateConnectTLS:(BOOL) isSecure +{ + self.directTLSInUse = isSecure; +} + +- (BOOL) isDirectTLS { + return self.directTLSInUse; +} + +@end diff --git a/Monal/Classes/MediaGallery.swift b/Monal/Classes/MediaGallery.swift new file mode 100644 index 0000000..2c667d5 --- /dev/null +++ b/Monal/Classes/MediaGallery.swift @@ -0,0 +1,216 @@ +// +// MediaGallery.swift +// Monal +// +// Created by Vaidik on 03.08.24. +// Copyright © 2021 Monal.im. All rights reserved. + +import SwiftUI +import AVKit +import AVFoundation + +struct MediaGalleryView: View { + @State private var mediaItems: [[String: Any]] = [] + let contact: String + let accountID: NSNumber + + var body: some View { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 10) { + ForEach(mediaItems.indices, id: \.self) { index in + NavigationLink(destination: LazyClosureView { + MediaItemSwipeView(currentItem: mediaItems[index], allItems: mediaItems) + }) { + MediaItemView(fileInfo: mediaItems[index]) + } + } + } + .padding() + } + .navigationTitle("Shared Media") + .onAppear { + fetchDownloadedMediaItems() + } + } + + private func fetchDownloadedMediaItems() { + if let attachments = DataLayer.sharedInstance().allAttachments(fromContact: contact, forAccount: accountID) as? [[String: Any]] { + mediaItems = attachments.filter { fileInfo in + if let mimeType = fileInfo["mimeType"] as? String, + !((fileInfo["needsDownloading"] as? NSNumber)?.boolValue ?? true) && + (mimeType.starts(with: "image/") || mimeType.starts(with: "video/")) { + return true + } + return false + } + } + } +} + +class MediaItem: Identifiable, ObservableObject { + let id = UUID() + let fileInfo: [String: Any] + @Published var thumbnail: UIImage? + + init(fileInfo: [String: Any]) { + self.fileInfo = fileInfo + self.thumbnail = nil + Task { + await generateThumbnail() + } + } + + @MainActor + func generateThumbnail() async { + guard let cacheFile = fileInfo["cacheFile"] as? String, let mimeType = fileInfo["mimeType"] as? String else { + DDLogError("Failed to get cacheFile or mimeType for: \(fileInfo)") + self.thumbnail = UIImage(systemName: "exclamationmark.triangle") + return + } + + if mimeType.starts(with: "image/") { + if let image = UIImage(contentsOfFile: cacheFile) { + self.thumbnail = image + } else { + DDLogError("Failed to generate image thumbnail for: \(fileInfo)") + self.thumbnail = UIImage(systemName: "photo") + } + return + } else if mimeType.starts(with: "video/") { + if let thumbnail = await videoPreview(for:fileInfo) { + self.thumbnail = thumbnail + } else { + DDLogError("Failed to generate video thumbnail for: \(fileInfo)") + self.thumbnail = UIImage(systemName: "video") + } + return + } + + DDLogError("Unsupported mime type: \(mimeType)") + self.thumbnail = UIImage(systemName: "doc") + } + + @MainActor + func videoPreview(for fileInfo: [String: Any]) async -> UIImage? { + let moviePath = URL(fileURLWithPath: fileInfo["cacheFile"] as! String) + DDLogInfo("Trying to generate video thumbnail for: \(String(describing:fileInfo))") + + var payload: NSMutableDictionary = [:] + HelperTools.addUploadItemPreview(forItem:moviePath, provider:nil, andPayload:payload) { newPayload in + payload = newPayload ?? [:] + } + guard let image = payload["preview"] as? UIImage else { + return try? await HelperTools.generateVideoThumbnail( + fromFile:fileInfo["cacheFile"] as! String, + havingMimeType:fileInfo["mimeType"] as! String, + andFileExtension:fileInfo["fileExtension"] as? String + ).toPromise().asyncOnMainActor() + } + return image + } +} + +struct MediaItemView: View { + @StateObject private var item: MediaItem + + init(fileInfo: [String: Any]) { + _item = StateObject(wrappedValue: MediaItem(fileInfo: fileInfo)) + } + + var body: some View { + ZStack { + Group { + if let thumbnail = item.thumbnail { + Image(uiImage: thumbnail) + .resizable() + //.scaledToFit() //leaves empty room around image if not having a square format + .scaledToFill() //this is what the ios gallery app uses (will crop the edges of that preview) + } else { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + } + .frame(width: 100, height: 100, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1)) + + // Add play icon overlay for video files + if let mimeType = item.fileInfo["mimeType"] as? String, mimeType.starts(with: "video/") { + Image(systemName: "play.circle.fill") + .resizable() + .frame(width: 30, height: 30) + .foregroundColor(.white) + .background(Color.black.opacity(0.5)) + .clipShape(Circle()) + } + } + } +} + +struct MediaItemDetailView: View { + @StateObject private var item: MediaItem + @StateObject private var dismisser = SheetDismisserProtocol() + + init(fileInfo: [String: Any]) { + _item = StateObject(wrappedValue: MediaItem(fileInfo: fileInfo)) + } + + var body: some View { + ImageViewerWrapper(info: item.fileInfo as [String: AnyObject], dismisser: dismisser) + .onAppear { + if let hostingController = UIApplication.shared.windows.first?.rootViewController?.presentedViewController as? UIHostingController { + dismisser.host = hostingController + } + } + } +} + +struct MediaItemSwipeView: View { + @State private var currentIndex: Int + let allItems: [[String: Any]] + + init(currentItem: [String: Any], allItems: [[String: Any]]) { + let index = allItems.firstIndex { item in + // Compare using 'cacheFile' + if let currentPath = currentItem["cacheFile"] as? String, + let itemPath = item["cacheFile"] as? String { + return currentPath == itemPath + } + return false + } ?? 0 + + self._currentIndex = State(initialValue: index) + self.allItems = allItems + } + + var body: some View { + TabView(selection: $currentIndex) { + ForEach(allItems.indices, id: \.self) { index in + MediaItemDetailView(fileInfo: allItems[index]) + .tag(index) + } + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .animation(.easeInOut, value: currentIndex) + .ignoresSafeArea() + .navigationBarHidden(true) + .statusBar(hidden: true) + } +} + +struct ImageViewerWrapper: View { + let info: [String: AnyObject] + let dismisser: SheetDismisserProtocol + + var body: some View { + Group { + if let _ = info["mimeType"] as? String { + try? ImageViewer(delegate: dismisser, info: info) + } else { + Text("Invalid file data") + } + } + } +} + + diff --git a/Monal/Classes/MediaViewer.swift b/Monal/Classes/MediaViewer.swift new file mode 100644 index 0000000..4e09483 --- /dev/null +++ b/Monal/Classes/MediaViewer.swift @@ -0,0 +1,243 @@ +// +// ImageViewer.swift +// Monal +// +// Created by Friedrich Altheide on 07.10.23. +// Copyright © 2023 monal-im.org. All rights reserved. +// + +import UniformTypeIdentifiers +import SVGView +import AVKit + +struct GifRepresentation: Transferable { + let getData: () -> Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .gif) { item in + { () -> Data in + return item.getData() + }() + } + } +} + +struct JpegRepresentation: Transferable { + let getData: () -> Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .jpeg) { item in + { () -> Data in + return item.getData() + }() + } + } +} + +struct SVGRepresentation: Transferable { + let getData: () -> Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .svg) { item in + { () -> Data in + return item.getData() + }() + } + } +} + +struct ImageViewer: View { + var delegate: SheetDismisserProtocol + let info: [String:AnyObject] + @State private var previewImage: UIImage? + @State private var controlsVisible = false + @StateObject private var customPlayer = CustomAVPlayer() + @State private var isPlayerReady = false + + init(delegate: SheetDismisserProtocol, info: [String:AnyObject]) throws { + self.delegate = delegate + self.info = info + } + + var body: some View { + ZStack(alignment: .top) { + Color.background + .ignoresSafeArea() + + if (info["mimeType"] as! String).hasPrefix("image/svg") { + VStack { + ZoomableContainer(maxScale:8.0, doubleTapScale:4.0) { + SVGView(contentsOf: URL(fileURLWithPath:info["cacheFile"] as! String)) + } + } + } else if (info["mimeType"] as! String).hasPrefix("image/") { + if let image = UIImage(contentsOfFile:info["cacheFile"] as! String) { + VStack { + ZoomableContainer(maxScale:8.0, doubleTapScale:4.0) { + if (info["mimeType"] as! String).hasPrefix("image/gif") { + GIFViewer(data:Binding(get: { try! NSData(contentsOfFile:info["cacheFile"] as! String) as Data }, set: { _ in })) + .scaledToFit() + } else { + Image(uiImage: image) + .resizable() + .scaledToFit() + } + } + } + } else { + InvalidFileView() + } + } else if (info["mimeType"] as! String).hasPrefix("video/") { + if isPlayerReady, let playerViewController = customPlayer.playerViewController { + ZoomableContainer(maxScale:8.0, doubleTapScale:4.0) { + AVPlayerControllerRepresentable(playerViewController: playerViewController) + } + } else { + ProgressView() + } + } else { + InvalidFileView() + } + + if controlsVisible { + ControlsOverlay(info: info, previewImage: $previewImage, dismiss: { + self.delegate.dismiss() + }) + } + }.onTapGesture(count: 1) { + controlsVisible.toggle() + }.task { + await loadPreviewAndConfigurePlayer() + } + } + + private func loadPreviewAndConfigurePlayer() async { + if (info["mimeType"] as! String).hasPrefix("image/svg") { + previewImage = await HelperTools.renderUIImage(fromSVGURL:URL(fileURLWithPath:info["cacheFile"] as! String)).toGuarantee().asyncOnMainActor() + } else if (info["mimeType"] as! String).hasPrefix("image/") { + previewImage = UIImage(contentsOfFile:info["cacheFile"] as! String) + } else if (info["mimeType"] as! String).hasPrefix("video/") { + if let filePath = info["cacheFile"] as? String, + let mimeType = info["mimeType"] as? String { + customPlayer.configurePlayer(filePath: filePath, mimeType: mimeType) + isPlayerReady = true + } + } + } +} + +class CustomAVPlayer: ObservableObject { + @Published var player: AVPlayer? + @Published var playerViewController: AVPlayerViewController? + + func configurePlayer(filePath: String, mimeType: String) { + // Clear existing player + player = nil + playerViewController = nil + + // Create URL + let videoFileUrl = URL(fileURLWithPath: filePath) + + // Create asset with MIME type + let videoAsset = AVURLAsset(url: videoFileUrl, options: [ + "AVURLAssetOutOfBandMIMETypeKey": mimeType + ]) + + // Create player and player view controller + let playerItem = AVPlayerItem(asset: videoAsset) + player = AVPlayer(playerItem: playerItem) + playerViewController = AVPlayerViewController() + playerViewController?.player = player + + DDLogDebug("Created AVPlayer(\(mimeType)): \(String(describing: player))") + } +} + +struct AVPlayerControllerRepresentable: UIViewControllerRepresentable { + let playerViewController: AVPlayerViewController + + func makeUIViewController(context: Context) -> AVPlayerViewController { + return playerViewController + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {} +} + +struct InvalidFileView: View { + var body: some View { + VStack { + Spacer() + Text("Invalid file!") + Spacer().frame(height: 24) + Image(systemName: "xmark.square.fill") + .resizable() + .frame(width: 128.0, height: 128.0) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .red) + Spacer() + } + } +} + +struct ControlsOverlay: View { + let info: [String: AnyObject] + @Binding var previewImage: UIImage? + let dismiss: () -> Void + + var body: some View { + VStack { + Color.background + .ignoresSafeArea() + .overlay( + HStack { + Spacer().frame(width: 20) + Text(info["filename"] as! String).foregroundColor(.primary) + Spacer() + + if let image = previewImage { + if (info["mimeType"] as! String).hasPrefix("image/svg") { + ShareLink( + item: SVGRepresentation(getData: { + try! NSData(contentsOfFile: info["cacheFile"] as! String) as Data + }), preview: SharePreview("Share image", image: Image(uiImage: image)) + ) + .labelStyle(.iconOnly) + .foregroundColor(.primary) + } else if (info["mimeType"] as! String).hasPrefix("image/gif") { + ShareLink( + item: GifRepresentation(getData: { + try! NSData(contentsOfFile: info["cacheFile"] as! String) as Data + }), preview: SharePreview("Share image", image: Image(uiImage: image)) + ) + .labelStyle(.iconOnly) + .foregroundColor(.primary) + } else if (info["mimeType"] as! String).hasPrefix("video/") { + if let fileURL = URL(string: info["cacheFile"] as! String) { + let mediaItem = MediaItem(fileInfo: info) + ShareLink(item: fileURL, preview: SharePreview("Share video", image: Image(uiImage: mediaItem.thumbnail ?? UIImage(systemName: "video")!))) + .labelStyle(.iconOnly) + .foregroundColor(.primary) + } + } else { + ShareLink( + item: JpegRepresentation(getData: { + try! NSData(contentsOfFile: info["cacheFile"] as! String) as Data + }), preview: SharePreview("Share image", image: Image(uiImage: image)) + ) + .labelStyle(.iconOnly) + .foregroundColor(.primary) + } + Spacer().frame(width: 20) + } + + Button(action: dismiss, label: { + Image(systemName: "xmark") + .foregroundColor(.primary) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 24))) + }) + Spacer().frame(width: 20) + } + ) + }.frame(height: 80) + } +} diff --git a/Monal/Classes/MemberList.swift b/Monal/Classes/MemberList.swift new file mode 100644 index 0000000..294359b --- /dev/null +++ b/Monal/Classes/MemberList.swift @@ -0,0 +1,367 @@ +// +// MemberList.swift +// Monal +// +// Created by Jan on 28.05.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +import OrderedCollections + +struct ActionSheetPrompt { + var title: Text = Text("") + var message: Text = Text("") + var closure: ()->Void = { } +} + +struct MemberList: View { + private let account: xmpp + @State private var ownAffiliation: String + @StateObject var muc: ObservableKVOWrapper + @State private var memberList: OrderedSet> + @State private var affiliations: Dictionary, String> + @State private var online: Dictionary, Bool> + @State private var nicknames: Dictionary, String> + @State private var navigationActive: ObservableKVOWrapper? + @State private var showAlert = false + @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close")) + @State private var showActionSheet = false + @State private var actionSheetPrompt = ActionSheetPrompt() + @StateObject private var overlay = LoadingOverlayState() + + init(mucContact: ObservableKVOWrapper) { + account = mucContact.obj.account! as xmpp + _muc = StateObject(wrappedValue:mucContact) + _ownAffiliation = State(wrappedValue:kMucAffiliationNone) + _memberList = State(wrappedValue:OrderedSet>()) + _affiliations = State(wrappedValue:[:]) + _online = State(wrappedValue:[:]) + _nicknames = State(wrappedValue:[:]) + } + + func updateMemberlist() { + memberList = getContactList(viewContact:self.muc) + ownAffiliation = DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:self.muc.obj) ?? kMucAffiliationNone + affiliations.removeAll(keepingCapacity:true) + online.removeAll(keepingCapacity:true) + nicknames.removeAll(keepingCapacity:true) + for memberInfo in Array(DataLayer.sharedInstance().getMembersAndParticipants(ofMuc:self.muc.contactJid, forAccountID:account.accountID)) { + DDLogVerbose("Got member/participant entry: \(String(describing:memberInfo))") + guard let jid = memberInfo["participant_jid"] as? String ?? memberInfo["member_jid"] as? String else { + continue + } + let contact = ObservableKVOWrapper(MLContact.createContact(fromJid:jid, andAccountID:account.accountID)) + nicknames[contact] = memberInfo["room_nick"] as? String + if !memberList.contains(contact) { + continue + } + affiliations[contact] = memberInfo["affiliation"] as? String ?? kMucAffiliationNone + if let num = memberInfo["online"] as? NSNumber { + online[contact] = num.boolValue + } else { + online[contact] = false + } + } + //this is needed to improve sorting speed + var contactNames: [ObservableKVOWrapper:String] = [:] + for contact in memberList { + contactNames[contact] = contact.obj.contactDisplayName(withFallback:nicknames[contact], andSelfnotesPrefix:false) + } + //sort our member list + memberList.sort { + ( + (online[$0]! ? 0 : 1), + mucAffiliationToInt(affiliations[$0]), + (contactNames[$0]!.lowercased()), + ($0.contactJid as String) + ) < ( + (online[$1]! ? 0 : 1), + mucAffiliationToInt(affiliations[$1]), + (contactNames[$1]!.lowercased()), + ($1.contactJid as String) + ) + } + } + + func promisifyAction(action: @escaping ()->Void) -> Promise { + return promisifyMucAction(account:self.account, mucJid:self.muc.contactJid, action:action) + } + + func showAlert(title: Text, description: Text) { + self.alertPrompt.title = title + self.alertPrompt.message = description + self.showAlert = true + } + + func showActionSheet(title: Text, description: Text, closure: @escaping ()->Void) { + self.actionSheetPrompt.title = title + self.actionSheetPrompt.message = description + self.actionSheetPrompt.closure = closure + self.showActionSheet = true + } + + func ownUserHasAffiliationToRemove(contact: ObservableKVOWrapper) -> Bool { + //we don't want to set affiliation=none in channels using deletion swipe (this does not delete the user) + if self.muc.mucType == kMucTypeChannel { + return false + } + if contact.contactJid == account.connectionProperties.identity.jid { + return false + } + if let contactAffiliation = affiliations[contact] { + if ownAffiliation == kMucAffiliationOwner { + return true + } else if ownAffiliation == kMucAffiliationAdmin && (contactAffiliation != kMucAffiliationOwner && contactAffiliation != kMucAffiliationAdmin) { + return true + } + } + return false + } + + func actionsAllowed(for contact:ObservableKVOWrapper) -> [String] { + if let contactAffiliation = affiliations[contact], let contactOnline = online[contact] { + var reinviteEntry: [String] = [] + if !contactOnline { + reinviteEntry = [kMucActionReinvite] + } + if self.muc.mucType == kMucTypeGroup { + if ownAffiliation == kMucAffiliationOwner { + return [/*kMucActionShowProfile*/] + reinviteEntry + [kMucAffiliationOwner, kMucAffiliationAdmin, kMucAffiliationMember, kMucAffiliationOutcast] + } else { //only admin left, because other affiliations don't call actionsAllowed at all + if [kMucAffiliationMember, kMucAffiliationOutcast].contains(contactAffiliation) { + return [/*kMucActionShowProfile*/] + reinviteEntry + [kMucAffiliationMember, kMucAffiliationOutcast] + } else { + //if this contact is a co-admin or owner, we aren't allowed to do much to their affiliation + //return contact affiliation because that should be displayed as selected in picker + return [/*kMucActionShowProfile*/] + reinviteEntry + [contactAffiliation] + } + } + } else { + if ownAffiliation == kMucAffiliationOwner { + return [/*kMucActionShowProfile*/] + reinviteEntry + [kMucAffiliationOwner, kMucAffiliationAdmin, kMucAffiliationMember, kMucAffiliationNone, kMucAffiliationOutcast] + } else { //only admin left, because other affiliations don't call actionsAllowed at all + if [kMucAffiliationMember, kMucAffiliationNone, kMucAffiliationOutcast].contains(contactAffiliation) { + return [/*kMucActionShowProfile*/] + reinviteEntry + [kMucAffiliationMember, kMucAffiliationNone, kMucAffiliationOutcast] + } else { + //if this contact is a co-admin or owner, we aren't allowed to do much to their affiliation + //return contact affiliation because that should be displayed as selected in picker + return [/*kMucActionShowProfile*/] + reinviteEntry + [contactAffiliation] + } + } + } + } + //fallback (should hopefully never be needed) + DDLogWarn("Fallback for group/channel \(String(describing:self.muc.contactJid as String)): affiliation=\(String(describing:affiliations[contact])), online=\(String(describing:online[contact]))") + if self.muc.mucType == kMucTypeGroup { + return [/*kMucActionShowProfile,*/ kMucActionReinvite] + } else { + return [/*kMucActionShowProfile,*/ kMucActionReinvite, kMucAffiliationNone] + } + } + + @ViewBuilder + func makePickerView(contact: ObservableKVOWrapper) -> some View { + Picker(selection: Binding( + get: { affiliations[contact] ?? kMucAffiliationNone }, + set: { newAffiliation in + if newAffiliation == affiliations[contact] { + return + } + if newAffiliation == kMucActionShowProfile { + DDLogVerbose("Activating navigation to \(String(describing:contact))") + navigationActive = contact + } else if newAffiliation == kMucActionReinvite { + //first remove potential ban, then reinvite + var outcastResolution: Promise = Promise.value(nil) + if affiliations[contact] == kMucAffiliationOutcast { + outcastResolution = showPromisingLoadingOverlay(self.overlay, headlineView: Text("Unblocking user"), descriptionView: Text("Unblocking user for this group/channel: \(contact.contactJid as String)")) { + promisifyAction { + account.mucProcessor.setAffiliation(self.muc.mucType == kMucTypeGroup ? kMucAffiliationMember : kMucAffiliationNone, ofUser:contact.contactJid, inMuc:self.muc.contactJid) + } + } + } + outcastResolution.then { _ in + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting user"), descriptionView: Text("Inviting user to this group/channel: \(contact.contactJid as String)")) { + promisifyAction { + account.mucProcessor.inviteUser(contact.contactJid, inMuc: self.muc.contactJid) + } + }.catch { error in + showAlert(title:Text("Error inviting user!"), description:Text("\(String(describing:error))")) + } + return Guarantee.value(()) + }.catch { error in + showAlert(title:Text("Error unblocking user!"), description:Text("\(String(describing:error))")) + } + } else if newAffiliation == kMucAffiliationOutcast { + showActionSheet(title: Text("Block user?"), description: Text("Do you want to block this user from entering this group/channel?")) { + DDLogVerbose("Changing affiliation of \(String(describing:contact)) to: \(String(describing:newAffiliation))...") + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Blocking member"), descriptionView: Text("Blocking \(contact.contactJid as String)")) { + promisifyAction { + account.mucProcessor.setAffiliation(newAffiliation, ofUser:contact.contactJid, inMuc:self.muc.contactJid) + } + }.catch { error in + showAlert(title:Text("Error blocking user!"), description:Text("\(String(describing:error))")) + } + } + } else { + DDLogVerbose("Changing affiliation of \(String(describing:contact)) to: \(String(describing:newAffiliation))...") + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Changing affiliation"), descriptionView: Text("Changing affiliation to \(mucAffiliationToString(affiliations[contact])): \(contact.contactJid as String)")) { + promisifyAction { + account.mucProcessor.setAffiliation(newAffiliation, ofUser:contact.contactJid, inMuc:self.muc.contactJid) + } + }.catch { error in + showAlert(title:Text("Error changing affiliation!"), description:Text("\(String(describing:error))")) + } + } + } + ), label: EmptyView()) { + ForEach(actionsAllowed(for:contact), id:\.self) { affiliation in + Text(mucAffiliationToString(affiliation)).tag(affiliation) + } + }.collapsedPickerStyle(accessibilityLabel: Text("Change affiliation")) + } + + var body: some View { + List { + Section(header: Text("\(self.muc.contactDisplayName as String) (affiliation: \(mucAffiliationToString(ownAffiliation)))")) { + if ownAffiliation == kMucAffiliationOwner || ownAffiliation == kMucAffiliationAdmin { + NavigationLink(destination: LazyClosureView(ContactPicker(account, initializeFrom: memberList, allowRemoval: false) { newMemberList in + for member in newMemberList { + if !memberList.contains(member) { + if self.muc.mucType == kMucTypeGroup { + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Adding new member"), descriptionView: Text("Adding \(member.contactJid as String)...")) { + promisifyAction { + account.mucProcessor.setAffiliation(kMucAffiliationMember, ofUser:member.contactJid, inMuc:self.muc.contactJid) + } + }.done { _ in + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting new member"), descriptionView: Text("Adding \(member.contactJid as String)...")) { + promisifyAction { + account.mucProcessor.inviteUser(member.contactJid, inMuc: self.muc.contactJid) + } + }.catch { error in + showAlert(title:Text("Error inviting new member!"), description:Text("\(String(describing:error))")) + } + }.catch { error in + showAlert(title:Text("Error adding new member!"), description:Text("\(String(describing:error))")) + } + } else { + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting new participant"), descriptionView: Text("Adding \(member.contactJid as String)...")) { + promisifyAction { + account.mucProcessor.inviteUser(member.contactJid, inMuc: self.muc.contactJid) + } + }.catch { error in + showAlert(title:Text("Error inviting new participant!"), description:Text("\(String(describing:error))")) + } + } + } + } + })) { + if self.muc.mucType == kMucTypeGroup { + Text("Add members to group") + } else { + Text("Invite participants to channel") + } + } + } + + ForEach(memberList, id:\.self) { contact in + var isDeletable: Bool { + ownUserHasAffiliationToRemove(contact: contact) + } + + if !contact.isSelfChat { + HStack { + HStack { + ContactEntry(contact:contact, fallback:nicknames[contact]) { + Text("Affiliation: \(mucAffiliationToString(affiliations[contact]))\(!(online[contact] ?? false) ? Text(" (offline)") : Text(""))") + //.foregroundColor(Color(UIColor.secondaryLabel)) + .font(.footnote) + } + Spacer() + } + .accessibilityLabel(Text("Open Profile of \(contact.contactDisplayName as String)")) + //invisible navigation link that can be triggered programmatically + .background( + NavigationLink(destination: LazyClosureView(ContactDetails(delegate:nil, contact:contact)), tag:contact, selection:$navigationActive) { EmptyView() } + .opacity(0) + ) + + if ownAffiliation == kMucAffiliationOwner || ownAffiliation == kMucAffiliationAdmin { + makePickerView(contact:contact) + .fixedSize() + .offset(x:8, y:0) + } + } + .applyClosure { view in + if !(online[contact] ?? false) { + view.opacity(0.5) + } else { + view + } + } + .swipeActions(allowsFullSwipe: false) { + Button("Delete") { + showActionSheet(title: Text("Remove \(mucAffiliationToString(affiliations[contact]))?"), description: self.muc.mucType == kMucTypeGroup ? Text("Do you want to remove that user from this group? That user won't be able to enter it again until added back to the group.") : Text("Do you want to remove that user from this channel? That user will be able to enter it again if you don't block them.")) { + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Removing \(mucAffiliationToString(affiliations[contact]))"), descriptionView: Text("Removing \(contact.contactJid as String)...")) { + promisifyAction { + account.mucProcessor.setAffiliation(kMucAffiliationNone, ofUser: contact.contactJid, inMuc: self.muc.contactJid) + } + }.catch { error in + showAlert(title:Text("Error removing user!"), description:Text("\(String(describing:error))")) + } + } + } + .tint(.red) + .disabled(!isDeletable) + } + } + } + } + } + .animation(.default, value: memberList) + .actionSheet(isPresented: $showActionSheet) { + ActionSheet( + title: actionSheetPrompt.title, + message: actionSheetPrompt.message, + buttons: [ + .cancel(), + .destructive( + Text("Yes"), + action: actionSheetPrompt.closure + ) + ] + ) + } + .alert(isPresented: $showAlert, content: { + Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton: .default(alertPrompt.dismissLabel)) + }) + .addLoadingOverlay(overlay) + .navigationBarTitle(Text("Group Members"), displayMode: .inline) + .onAppear { + updateMemberlist() + } + .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)...") + //only trigger update if we are either in a group type muc or have admin/owner priviledges + //all other cases will close this view anyways, it makes no sense to update everything directly before hiding thsi view + if contact == self.muc && (contact.mucType == kMucTypeGroup || [kMucAffiliationOwner, kMucAffiliationAdmin].contains(DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:self.muc.obj) ?? kMucAffiliationNone)) { + updateMemberlist() + } + } + } + } +} + +extension UIPickerView { + override open func didMoveToSuperview() { + super.didMoveToSuperview() + self.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } +} + +struct MemberList_Previews: PreviewProvider { + static var previews: some View { + MemberList(mucContact:ObservableKVOWrapper(MLContact.makeDummyContact(2))); + } +} diff --git a/Monal/Classes/Monal Tests-Bridging-Header.h b/Monal/Classes/Monal Tests-Bridging-Header.h new file mode 100644 index 0000000..1b2cb5d --- /dev/null +++ b/Monal/Classes/Monal Tests-Bridging-Header.h @@ -0,0 +1,4 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + diff --git a/Monal/Classes/Monal-Bridging-Header.h b/Monal/Classes/Monal-Bridging-Header.h new file mode 100644 index 0000000..ef3d166 --- /dev/null +++ b/Monal/Classes/Monal-Bridging-Header.h @@ -0,0 +1,4 @@ +#import "MonalAppDelegate.h" +#import "ActiveChatsViewController.h" +#import "MLMucProcessor.h" +#import "SCRAM.h" diff --git a/Monal/Classes/MonalAppDelegate.h b/Monal/Classes/MonalAppDelegate.h new file mode 100644 index 0000000..6841928 --- /dev/null +++ b/Monal/Classes/MonalAppDelegate.h @@ -0,0 +1,37 @@ +// +// SworIMAppDelegate.h +// SworIM +// +// Created by Anurodh Pokharel on 11/16/08. +// Copyright __MyCompanyName__ 2008. All rights reserved. +// + +#import "MLConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@import UIKit; +@import UserNotifications; + +@class ActiveChatsViewController; +@class MLContact; +@class MLVoIPProcessor; + +@interface MonalAppDelegate : UIResponder + +@property (nonatomic, strong) UIWindow* _Nullable window; +@property (nonatomic, weak) ActiveChatsViewController* _Nullable activeChats; +@property (nonatomic, strong) MLVoIPProcessor* _Nullable voipProcessor; +@property (nonatomic, assign) MLAudioState audioState; +@property (nonatomic) UIInterfaceOrientationMask orientationLock; + +-(UIViewController*) getTopViewController; +-(void) updateUnread; +-(void) handleXMPPURL:(NSURL* _Nonnull) url; +-(void) openChatOfContact:(MLContact* _Nullable) contact; +-(void) openChatOfContact:(MLContact* _Nullable) contact withCompletion:(monal_id_block_t _Nullable) completion; +-(void) incomingWakeupWithCompletionHandler:(void (^)(UIBackgroundFetchResult result)) completionHandler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MonalAppDelegate.m b/Monal/Classes/MonalAppDelegate.m new file mode 100644 index 0000000..7086683 --- /dev/null +++ b/Monal/Classes/MonalAppDelegate.m @@ -0,0 +1,2033 @@ +// +// SworIMAppDelegate.m +// SworIM +// +// Created by Anurodh Pokharel on 11/16/08. +// Copyright __MyCompanyName__ 2008. All rights reserved. +// + +#import +#import "MonalAppDelegate.h" +#import "MLConstants.h" +#import "HelperTools.h" +#import "MLNotificationManager.h" +#import "DataLayer.h" +#import "MLImageManager.h" +#import "ActiveChatsViewController.h" +#import "IPC.h" +#import "MLProcessLock.h" +#import "MLFiletransfer.h" +#import "xmpp.h" +#import "MLNotificationQueue.h" +#import "MLSettingsAboutViewController.h" +#import "MLMucProcessor.h" +#import "MBProgressHUD.h" +#import "MLVoIPProcessor.h" +#import "MLUDPLogger.h" +#import "MLCrashReporter.h" + +@import NotificationBannerSwift; +@import UserNotifications; + +#import "MLXMPPManager.h" + +#import + +#import "MLBasePaser.h" +#import "MLXMLNode.h" +#import "XMPPStanza.h" +#import "XMPPDataForm.h" +#import "XMPPIQ.h" +#import "XMPPPresence.h" +#import "XMPPMessage.h" +#import "chatViewController.h" + +@import Intents; + +#define GRACEFUL_TIMEOUT 20.0 +#define BGPROCESS_GRACEFUL_TIMEOUT 60.0 + +typedef void (^pushCompletion)(UIBackgroundFetchResult result); + +@interface MonalAppDelegate() +{ + NSMutableDictionary* _wakeupCompletions; + UIBackgroundTaskIdentifier _bgTask; + BGTask* _bgProcessing; + BGTask* _bgRefreshing; + monal_void_block_t _backgroundTimer; + MLContact* _contactToOpen; + monal_id_block_t _completionToCall; + BOOL _shutdownPending; + BOOL _wasFreezed; +} +@end + +@implementation MonalAppDelegate + +// **************************** xml parser and query language tests **************************** +-(void) runParserTests +{ + NSString* xml = @"\n\ + \n\ + SCRAM-SHA-1PLAIN\n\ + \n\ + Message text\n\ + This will NOT be used\n\ + \n\ + http://jabber.org/protocol/muc#roominfo200testchat gruppe\n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n\ + urn:xmpp:dataforms:softwareinfo\n\ + \n\ + \n\ + ipv4\n\ + ipv6\n\ + \n\ + \n\ + Mac\n\ + \n\ + \n\ + 10.5.1\n\ + \n\ + \n\ + Psi\n\ + \n\ + \n\ + 0.11\n\ + \n\ + \n\ + \n\ + \n\ +"; + DDLogInfo(@"creating parser delegate for xml: %@", xml); +//yes, but this is not insecure because these are string literals boxed into an NSArray below rather than containing unchecked user input +//see here: https://releases.llvm.org/13.0.0/tools/clang/docs/DiagnosticsReference.html#wformat-security +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wformat-security" + MLBasePaser* delegate = [[MLBasePaser alloc] initWithCompletion:^(MLXMLNode* _Nullable parsedStanza) { + if(parsedStanza != nil) + { + DDLogInfo(@"Got new parsed stanza: %@", parsedStanza); + for(NSString* query in @[ + @"{http://jabber.org/protocol/disco#info}query/\\{http://jabber.org/protocol/muc#roominfo}result@muc#roomconfig_roomname\\", + @"/{jabber:client}iq/{http://jabber.org/protocol/pubsub}pubsub/items@node", + @"body#", + ]) + { + id result = [parsedStanza find:query]; + DDLogDebug(@"Query: '%@', result: '%@'", query, result); + } + NSString* specialQuery1 = @"//{http://jabber.org/protocol/pubsub}pubsub/subscription"; + id result = [parsedStanza find:specialQuery1, @"result", @"eu.siacs.conversations.axolotl.devicelist", "subscribed", @"user@example.com"]; + DDLogDebug(@"Query: '%@', result: '%@'", specialQuery1, result); + + //handle gajim disco hash testcase + if([parsedStanza check:@"/"]) + { + //the the original implementation in MLIQProcessor $$class_handler(handleEntityCapsDisco) + NSMutableArray* identities = [NSMutableArray new]; + for(MLXMLNode* identity in [parsedStanza find:@"{http://jabber.org/protocol/disco#info}query/identity"]) + [identities addObject:[NSString stringWithFormat:@"%@/%@/%@/%@", [identity findFirst:@"/@category"], [identity findFirst:@"/@type"], ([identity check:@"/@xml:lang"] ? [identity findFirst:@"/@xml:lang"] : @""), ([identity check:@"/@name"] ? [identity findFirst:@"/@name"] : @"")]]; + NSSet* features = [NSSet setWithArray:[parsedStanza find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; + NSArray* forms = [parsedStanza find:@"{http://jabber.org/protocol/disco#info}query/{jabber:x:data}x"]; + NSString* ver = [HelperTools getEntityCapsHashForIdentities:identities andFeatures:features andForms:forms]; + DDLogDebug(@"Caps hash calculated: %@", ver); + MLAssert([@"q07IKJEyjvHSyhy//CH0CxmKi8w=" isEqualToString:ver], @"Caps hash NOT equal to testcase hash 'q07IKJEyjvHSyhy//CH0CxmKi8w='!"); + } + } + }]; +#pragma clang diagnostic pop + + //create xml parser, configure our delegate and feed it with data + NSXMLParser* xmlParser = [[NSXMLParser alloc] initWithData:[xml dataUsingEncoding:NSUTF8StringEncoding]]; + [xmlParser setShouldProcessNamespaces:YES]; + [xmlParser setShouldReportNamespacePrefixes:YES]; //for debugging only + [xmlParser setShouldResolveExternalEntities:NO]; + [xmlParser setDelegate:delegate]; + DDLogInfo(@"calling parse"); + [xmlParser parse]; //blocking operation + DDLogInfo(@"parse ended"); + [DDLog flushLog]; +//make sure apple's code analyzer will not reject the app for the appstore because of our call to exit() +#ifdef IS_ALPHA + exit(0); +#endif +} + +-(void) runSDPTests +{ + DDLogVerbose(@"SDP2XML: %@", [HelperTools sdp2xml:@"v=0\n\ +o=- 2005859539484728435 2 IN IP4 127.0.0.1\n\ +s=-\n\ +t=0 0\n\ +a=group:BUNDLE 0 1 2\n\ +a=extmap-allow-mixed\n\ +a=msid-semantic: WMS stream\n\ +m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 102 0 8 13 110 126\n\ +c=IN IP4 0.0.0.0\n\ +a=candidate:1076231993 2 udp 41885694 198.51.100.52 50002 typ relay raddr 0.0.0.0 rport 0 generation 0 ufrag V4as network-id 2 network-cost 10\n\ +a=rtcp:9 IN IP4 0.0.0.0\n\ +a=ice-ufrag:Pt2c\n\ +a=ice-pwd:XKe021opw+vupIkkLCI1+kP4\n\ +a=ice-options:trickle renomination\n\ +a=fingerprint:sha-256 1F:CE:47:40:5F:F2:FC:66:F2:21:F7:7D:3D:D6:0D:B0:67:6F:BD:CF:8B:0E:B7:90:5D:8C:33:9E:AD:F2:CB:FC\n\ +a=setup:actpass\n\ +a=mid:0\n\ +a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\n\ +a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\n\ +a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n\ +a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\n\ +a=sendrecv\n\ +a=msid:stream audio0\n\ +a=rtcp-mux\n\ +a=rtpmap:111 opus/48000/2\n\ +a=rtcp-fb:111 transport-cc\n\ +a=fmtp:111 minptime=10;useinbandfec=1\n\ +a=rtpmap:63 red/48000/2\n\ +a=fmtp:63 111/111\n\ +a=rtpmap:9 G722/8000\n\ +a=rtpmap:102 ILBC/8000\n\ +a=rtpmap:0 PCMU/8000\n\ +a=rtpmap:8 PCMA/8000\n\ +a=rtpmap:13 CN/8000\n\ +a=rtpmap:110 telephone-event/48000\n\ +a=rtpmap:126 telephone-event/8000\n\ +a=ssrc:109112503 cname:vUpPwDICjVuwEwGO\n\ +a=ssrc:109112503 msid:stream audio0\n\ +m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 127 103 35 36 104 105 106\n\ +c=IN IP4 0.0.0.0\n\ +a=rtcp:9 IN IP4 0.0.0.0\n\ +a=ice-ufrag:Pt2c\n\ +a=ice-pwd:XKe021opw+vupIkkLCI1+kP4\n\ +a=ice-options:trickle renomination\n\ +a=fingerprint:sha-256 1F:CE:47:40:5F:F2:FC:66:F2:21:F7:7D:3D:D6:0D:B0:67:6F:BD:CF:8B:0E:B7:90:5D:8C:33:9E:AD:F2:CB:FC\n\ +a=setup:actpass\n\ +a=mid:1\n\ +a=extmap:14 urn:ietf:params:rtp-hdrext:toffset\n\ +a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\n\ +a=extmap:13 urn:3gpp:video-orientation\n\ +a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n\ +a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\n\ +a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\n\ +a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\n\ +a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\n\ +a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\n\ +a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n\ +a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n\ +a=sendrecv\n\ +a=msid:stream video0\n\ +a=rtcp-mux\n\ +a=rtcp-rsize\n\ +a=rtpmap:96 H264/90000\n\ +a=rtcp-fb:96 goog-remb\n\ +a=rtcp-fb:96 transport-cc\n\ +a=rtcp-fb:96 ccm fir\n\ +a=rtcp-fb:96 nack\n\ +a=rtcp-fb:96 nack pli\n\ +a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c34\n\ +a=rtpmap:97 rtx/90000\n\ +a=fmtp:97 apt=96\n\ +a=rtpmap:98 H264/90000\n\ +a=rtcp-fb:98 goog-remb\n\ +a=rtcp-fb:98 transport-cc\n\ +a=rtcp-fb:98 ccm fir\n\ +a=rtcp-fb:98 nack\n\ +a=rtcp-fb:98 nack pli\n\ +a=fmtp:98 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e034\n\ +a=rtpmap:99 rtx/90000\n\ +a=fmtp:99 apt=98\n\ +a=rtpmap:100 VP8/90000\n\ +a=rtcp-fb:100 goog-remb\n\ +a=rtcp-fb:100 transport-cc\n\ +a=rtcp-fb:100 ccm fir\n\ +a=rtcp-fb:100 nack\n\ +a=rtcp-fb:100 nack pli\n\ +a=rtpmap:101 rtx/90000\n\ +a=fmtp:101 apt=100\n\ +a=rtpmap:127 VP9/90000\n\ +a=rtcp-fb:127 goog-remb\n\ +a=rtcp-fb:127 transport-cc\n\ +a=rtcp-fb:127 ccm fir\n\ +a=rtcp-fb:127 nack\n\ +a=rtcp-fb:127 nack pli\n\ +a=rtpmap:103 rtx/90000\n\ +a=fmtp:103 apt=127\n\ +a=rtpmap:35 AV1/90000\n\ +a=rtcp-fb:35 goog-remb\n\ +a=rtcp-fb:35 transport-cc\n\ +a=rtcp-fb:35 ccm fir\n\ +a=rtcp-fb:35 nack\n\ +a=rtcp-fb:35 nack pli\n\ +a=rtpmap:36 rtx/90000\n\ +a=fmtp:36 apt=35\n\ +a=rtpmap:104 red/90000\n\ +a=rtpmap:105 rtx/90000\n\ +a=fmtp:105 apt=104\n\ +a=rtpmap:106 ulpfec/90000\n\ +a=ssrc-group:FID 3733210709 4025710505\n\ +a=ssrc:3733210709 cname:vUpPwDICjVuwEwGO\n\ +a=ssrc:3733210709 msid:stream video0\n\ +a=ssrc:4025710505 cname:vUpPwDICjVuwEwGO\n\ +a=ssrc:4025710505 msid:stream video0\n\ +m=application 9 UDP/DTLS/SCTP webrtc-datachannel\n\ +c=IN IP4 0.0.0.0\n\ +a=ice-ufrag:Pt2c\n\ +a=ice-pwd:XKe021opw+vupIkkLCI1+kP4\n\ +a=ice-options:trickle renomination\n\ +a=fingerprint:sha-256 1F:CE:47:40:5F:F2:FC:66:F2:21:F7:7D:3D:D6:0D:B0:67:6F:BD:CF:8B:0E:B7:90:5D:8C:33:9E:AD:F2:CB:FC\n\ +a=setup:actpass\n\ +a=mid:2\n\ +a=sctp-port:5000\n\ +a=max-message-size:262144\n" withInitiator:YES]); +} + +$$class_handler(handlerTest01, $$ID(NSObject*, dummyObj)) + DDLogError(@"HandlerTest01 completed"); +$$ + +$$class_handler(handlerTest02, $$ID(monal_void_block_t, dummyCallback)) + DDLogError(@"HandlerTest02 completed"); +$$ + +-(void) runHandlerTests +{ + DDLogError(@"NSClassFromString: '%@'", NSClassFromString(@"monal_void_block_t")); + + if([^{} isKindOfClass:[NSObject class]]) + DDLogError(@"isKindOfClass"); + + MLHandler* handler01 = $newHandler([self class], handlerTest01); + $call(handler01, $ID(dummyObj, [NSString new])); + + MLHandler* handler02 = $newHandler([self class], handlerTest02); + $call(handler02, $ID(dummyCallback, ^{})); +} + +-(id) init +{ + //someone (suspect: AppKit) resets our exception handler between the call to [MonalAppDelegate initialize] and [MonalAppDelegate init] + [HelperTools installExceptionHandler]; + + self = [super init]; + _bgTask = UIBackgroundTaskInvalid; + _wakeupCompletions = [NSMutableDictionary new]; + DDLogVerbose(@"Setting _shutdownPending to NO..."); + _shutdownPending = NO; + _wasFreezed = NO; + + //[self runParserTests]; + //[self runSDPTests]; + //[HelperTools flushLogsWithTimeout:0.250]; + //[self runHandlerTests]; + return self; +} + +#pragma mark - APNS notification + +-(void) application:(UIApplication*) application didRegisterForRemoteNotificationsWithDeviceToken:(NSData*) deviceToken +{ + NSString* token = [HelperTools stringFromToken:deviceToken]; + DDLogInfo(@"APNS token string: %@", token); + [[MLXMPPManager sharedInstance] setPushToken:token]; +} + +-(void) application:(UIApplication*) application didFailToRegisterForRemoteNotificationsWithError:(NSError*) error +{ + DDLogError(@"APNS push reg error %@", error); + [[MLXMPPManager sharedInstance] removeToken]; + [MLXMPPManager sharedInstance].apnsError = error; +} + +#pragma mark - notification actions + +-(void) updateUnread +{ + DDLogInfo(@"Updating unread called"); + //make sure unread badge matches application badge + NSNumber* unreadMsgCnt = [[DataLayer sharedInstance] countUnreadMessages]; + [HelperTools dispatchAsync:YES reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ + NSInteger unread = 0; + if(unreadMsgCnt != nil) + unread = [unreadMsgCnt integerValue]; + DDLogInfo(@"Updating unread badge to: %ld", (long)unread); + [[UNUserNotificationCenter currentNotificationCenter] setBadgeCount:unread withCompletionHandler:nil]; + }]; +} + +#pragma mark - app life cycle + +-(BOOL) application:(UIApplication*) application willFinishLaunchingWithOptions:(NSDictionary*) launchOptions +{ + DDLogInfo(@"App launching with options: %@", launchOptions); + + //init IPC and ProcessLock + [IPC initializeForProcess:@"MainApp"]; + [MLProcessLock initializeForProcess:@"MainApp"]; + + //lock process and disconnect an already running NotificationServiceExtension + [MLProcessLock lock]; + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + + //do MLFiletransfer cleanup tasks (do this in a new thread to parallelize it with our ping to the appex and don't slow down app startup) + //this will also migrate our old image cache to new MLFiletransfer cache + //BUT: don't do this if we are sending the sharesheet outbox + if(launchOptions[UIApplicationLaunchOptionsURLKey] == nil || ![launchOptions[UIApplicationLaunchOptionsURLKey] isEqual:kMonalOpenURL]) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + [MLFiletransfer doStartupCleanup]; + }); + + //do image manager cleanup in a new thread to not slow down app startup + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + [[MLImageManager sharedInstance] cleanupHashes]; + }); + + //only proceed with launching if the NotificationServiceExtension is *not* running + if([MLProcessLock checkRemoteRunning:@"NotificationServiceExtension"]) + { + DDLogInfo(@"NotificationServiceExtension is running, waiting for its termination"); + [MLProcessLock waitForRemoteTermination:@"NotificationServiceExtension" withLoopHandler:^{ + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + }]; + } + + return YES; +} + +-(BOOL) application:(UIApplication*) application didFinishLaunchingWithOptions:(NSDictionary*) launchOptions +{ + //this will use the cached values in defaultsDB, if possible + [[MLXMPPManager sharedInstance] setPushToken:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleScheduleBackgroundTaskNotification:) name:kScheduleBackgroundTask object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(nowIdle:) name:kMonalIdle object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(filetransfersNowIdle:) name:kMonalFiletransfersIdle object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(nowNotIdle:) name:kMonalNotIdle object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(showConnectionStatus:) name:kXMPPError object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateUnread) name:kMonalNewMessageNotice object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateUnread) name:kMonalUpdateUnread object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(prepareForFreeze:) name:kMonalWillBeFreezed object:nil]; + + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + center.delegate = self; + + //create notification categories with actions + UNNotificationAction* replyAction = [UNTextInputNotificationAction + actionWithIdentifier:@"REPLY_ACTION" + title:NSLocalizedString(@"Reply", @"") + options:UNNotificationActionOptionNone + icon:[UNNotificationActionIcon iconWithSystemImageName:@"arrowshape.turn.up.left"] + textInputButtonTitle:NSLocalizedString(@"Send", @"") + textInputPlaceholder:NSLocalizedString(@"Your answer", @"") + ]; + UNNotificationAction* markAsReadAction = [UNNotificationAction + actionWithIdentifier:@"MARK_AS_READ_ACTION" + title:NSLocalizedString(@"Mark as read", @"") + options:UNNotificationActionOptionNone + icon:[UNNotificationActionIcon iconWithSystemImageName:@"checkmark.bubble"] + ]; + UNNotificationAction* approveSubscriptionAction = [UNNotificationAction + actionWithIdentifier:@"APPROVE_SUBSCRIPTION_ACTION" + title:NSLocalizedString(@"Approve new contact", @"") + options:UNNotificationActionOptionNone + icon:[UNNotificationActionIcon iconWithSystemImageName:@"person.crop.circle.badge.checkmark"] + ]; + UNNotificationAction* denySubscriptionAction = [UNNotificationAction + actionWithIdentifier:@"DENY_SUBSCRIPTION_ACTION" + title:NSLocalizedString(@"Deny new contact", @"") + options:UNNotificationActionOptionNone + icon:[UNNotificationActionIcon iconWithSystemImageName:@"person.crop.circle.badge.xmark"] + ]; + + UNAuthorizationOptions authOptions = UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert | UNAuthorizationOptionProvidesAppNotificationSettings; +#if TARGET_OS_MACCATALYST + authOptions |= UNAuthorizationOptionProvisional; +#endif + UNNotificationCategory* messageCategory = [UNNotificationCategory + categoryWithIdentifier:@"message" + actions:@[replyAction, markAsReadAction] + intentIdentifiers:@[] + options:UNNotificationCategoryOptionNone + ]; + UNNotificationCategory* subscriptionCategory = [UNNotificationCategory + categoryWithIdentifier:@"subscription" + actions:@[approveSubscriptionAction, denySubscriptionAction] + intentIdentifiers:@[] + options:UNNotificationCategoryOptionCustomDismissAction + ]; + + [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* settings) { + DDLogInfo(@"Current notification settings: %@", settings); + }]; + + //request auth to show notifications and register our notification categories created above + [center requestAuthorizationWithOptions:authOptions completionHandler:^(BOOL granted, NSError* error) { + dispatch_async(dispatch_get_main_queue(), ^{ + DDLogInfo(@"Got local notification authorization response: granted=%@, error=%@", bool2str(granted), error); + BOOL oldGranted = [[HelperTools defaultsDB] boolForKey:@"notificationsGranted"]; + [[HelperTools defaultsDB] setBool:granted forKey:@"notificationsGranted"]; + if(granted == YES) + { + if(!oldGranted) + { + //this is only needed for better UI (settings --> noifications should reflect the proper state) + //both invalidations are needed because we don't know the timing of this notification granting handler + DDLogInfo(@"Invalidating all account states..."); + [[DataLayer sharedInstance] invalidateAllAccountStates]; //invalidate states for account objects not yet created + [[MLXMPPManager sharedInstance] reconnectAll]; //invalidate for account objects already created + } + + //activate push + DDLogInfo(@"Registering for APNS..."); + [[UIApplication sharedApplication] registerForRemoteNotifications]; + [self->_voipProcessor voipRegistration]; + } + else + { + //delete apns push token --> push will not be registered on our xmpp server anymore + DDLogWarn(@"Notifications disabled --> deleting APNS push token from user defaults!"); + NSString* oldToken = [[HelperTools defaultsDB] objectForKey:@"pushToken"]; + [[MLXMPPManager sharedInstance] removeToken]; + + if((oldToken != nil && oldToken.length != 0) || oldGranted) + { + //this is only needed for better UI (settings --> noifications should reflect the proper state) + //both invalidations are needed because we don't know the timing of this notification granting handler + DDLogInfo(@"Invalidating all account states..."); + [[DataLayer sharedInstance] invalidateAllAccountStates]; //invalidate states for account objects not yet created + [[MLXMPPManager sharedInstance] reconnectAll]; //invalidate for account objects already created + } + } + }); + }]; + [center setNotificationCategories:[NSSet setWithObjects:messageCategory, subscriptionCategory , nil]]; + + UINavigationBarAppearance* appearance = [UINavigationBarAppearance new]; + [appearance configureWithTransparentBackground]; + appearance.backgroundColor = [UIColor systemBackgroundColor]; + + [[UINavigationBar appearance] setScrollEdgeAppearance:appearance]; + [[UINavigationBar appearance] setStandardAppearance:appearance]; +#if TARGET_OS_MACCATALYST + self.window.windowScene.titlebar.titleVisibility = UITitlebarTitleVisibilityHidden; +#endif + [[UINavigationBar appearance] setPrefersLargeTitles:YES]; + + //handle message notifications by initializing the MLNotificationManager + [MLNotificationManager sharedInstance]; + + //register BGTask + DDLogInfo(@"calling MonalAppDelegate configureBackgroundTasks"); + [self configureBackgroundTasks]; + + // Play audio even if phone is in silent mode + [HelperTools configureDefaultAudioSession]; + self.audioState = MLAudioStateNormal; + + DDLogInfo(@"App started: %@", [HelperTools appBuildVersionInfoFor:MLVersionTypeLog]); + + //init background/foreground status + //this has to be done here to make sure we have the correct state when he app got started through notification quick actions + //NOTE: the connectedXMPP array does not exist at this point --> calling this methods only updates the state without messing with the accounts themselves + if([UIApplication sharedApplication].applicationState==UIApplicationStateBackground) + [[MLXMPPManager sharedInstance] nowBackgrounded]; + else + [[MLXMPPManager sharedInstance] nowForegrounded]; + + @synchronized(self) { + DDLogVerbose(@"Setting _shutdownPending to NO..."); + _shutdownPending = NO; + } + [self addBackgroundTask]; + + //should any accounts connect? + [self connectIfNecessaryWithOptions:launchOptions]; + + //handle IPC messages (this should be done *after* calling connectIfNecessary to make sure any disconnectAll messages are handled properly + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(incomingIPC:) name:kMonalIncomingIPC object:nil]; + +#if TARGET_OS_MACCATALYST + //handle catalyst foregrounding/backgrounding of window + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowHandling:) name:@"NSWindowDidResignKeyNotification" object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowHandling:) name:@"NSWindowDidBecomeKeyNotification" object:nil]; +#endif + + //initialize callkit (mus be done after connectIfNecessary to make sure the list of accounts is already populated when a voip push comes in) + _voipProcessor = [MLVoIPProcessor new]; + + /* + NSDictionary* options = launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; + if(options != nil && [@"INSendMessageIntent" isEqualToString:options[UIApplicationLaunchOptionsUserActivityTypeKey]]) + { + NSUserActivity* userActivity = options[@"UIApplicationLaunchOptionsUserActivityKey"]; + DDLogError(@"intent: %@", userActivity.interaction); + } + */ + + return YES; +} + +-(BOOL) application:(UIApplication*) application continueUserActivity:(NSUserActivity*) userActivity restorationHandler:(void (^)(NSArray>* restorableObjects)) restorationHandler +{ + DDLogDebug(@"Got continueUserActivity call..."); + if([userActivity.interaction.intent isKindOfClass:[INStartCallIntent class]]) + { + DDLogInfo(@"INStartCallIntent interaction: %@", userActivity.interaction); + INStartCallIntent* intent = (INStartCallIntent*)userActivity.interaction.intent; + if(intent.contacts.firstObject != nil) + { + INPersonHandle* contactHandle = intent.contacts.firstObject.personHandle; + DDLogInfo(@"INStartCallIntent with contact: %@", contactHandle.value); + NSArray* contacts = [[DataLayer sharedInstance] contactListWithJid:contactHandle.value]; + if([contacts count] == 0) + { + [self.activeChats showCallContactNotFoundAlert:contactHandle.value]; + return NO; + } + //don't display account picker or open call ui if we have an already active call with any of the possible contacts + //the call ui will be brought into foreground by applicationWillEnterForeground: independently of this + for(MLContact* contact in contacts) + if([self.voipProcessor getActiveCallWithContact:contact] != nil) + return YES; + MLCallType callType = MLCallTypeAudio; //default is audio call + if(intent.callCapability == INCallCapabilityVideoCall) + callType = MLCallTypeVideo; + if([contacts count] > 1) + [self.activeChats presentAccountPickerForContacts:contacts andCallType:callType]; + else + [self.activeChats callContact:contacts.firstObject withCallType:callType]; + return YES; + } + } + else if([userActivity.interaction.intent isKindOfClass:[INSendMessageIntent class]]) + { + DDLogError(@"Got INSendMessageIntent: %@", (INSendMessageIntent*)userActivity.interaction.intent); + } + return NO; +} + +-(id) application:(UIApplication*) application handlerForIntent:(INIntent*) intent +{ + DDLogError(@"Got intent: %@", intent); + return nil; +} + +#if TARGET_OS_MACCATALYST +-(void) windowHandling:(NSNotification*) notification +{ + if([notification.name isEqualToString:@"NSWindowDidResignKeyNotification"]) + { + DDLogInfo(@"Window lost focus (key window)..."); + [self updateUnread]; + if(NSProcessInfo.processInfo.isLowPowerModeEnabled) + { + DDLogInfo(@"LowPowerMode is active: nowReallyBackgrounded to reduce power consumption"); + [self nowReallyBackgrounded]; + } + else + [[MLXMPPManager sharedInstance] noLongerInFocus]; + } + else if([notification.name isEqualToString:@"NSWindowDidBecomeKeyNotification"]) + { + DDLogInfo(@"Window got focus (key window)..."); + [MLProcessLock lock]; + @synchronized(self) { + DDLogVerbose(@"Setting _shutdownPending to NO..."); + _shutdownPending = NO; + } + + //cancel already running background timer, we are now foregrounded again + [self stopBackgroundTimer]; + + [self addBackgroundTask]; + [[MLXMPPManager sharedInstance] nowForegrounded]; + } +} +#endif + +-(void) incomingIPC:(NSNotification*) notification +{ + NSDictionary* message = notification.userInfo; + //another process tells us to disconnect all accounts + //this could happen if we are connecting (or even connected) in the background and the NotificationServiceExtension got started + //BUT: only do this if we are in background (we should never receive this if we are foregrounded) + MLAssert(![message[@"name"] isEqualToString:@"Monal.disconnectAll"], @"Got 'Monal.disconnectAll' while in mainapp. This should NEVER happen!", message); + if([message[@"name"] isEqualToString:@"Monal.connectIfNecessary"]) + { + DDLogInfo(@"Got connectIfNecessary IPC message"); + //(re)connect all accounts + [self connectIfNecessaryWithOptions:nil]; + } +} + +-(void) applicationDidBecomeActive:(UIApplication*) application +{ + if([[MLXMPPManager sharedInstance] connectedXMPP].count > 0) + [self handleSpinner]; + else + { + //hide spinner + [self.activeChats.spinner stopAnimating]; + } + + //report pending crashes + [MLCrashReporter reportPendingCrashes]; +} + +-(void) setActiveChats:(UIViewController*) activeChats +{ + DDLogDebug(@"Active chats did load..."); + _activeChats = (ActiveChatsViewController*)activeChats; + [self openChatOfContact:_contactToOpen withCompletion:_completionToCall]; +} + +#pragma mark - handling urls + +/** + xmpp:romeo@montague.net?message;subject=Test%20Message;body=Here%27s%20a%20test%20message + xmpp:coven@chat.shakespeare.lit?join;password=cauldronburn + + xmpp:example.com?register;preauth=3c7efeafc1bb10d034 + xmpp:romeo@example.com?register;preauth=3c7efeafc1bb10d034 + xmpp:contact@example.com?roster;preauth=3c7efeafc1bb10d034 + xmpp:contact@example.com?roster;preauth=3c7efeafc1bb10d034;ibr=y + + @link https://xmpp.org/extensions/xep-0147.html + @link https://docs.modernxmpp.org/client/invites/ + */ +-(void) handleXMPPURL:(NSURL*) url +{ + //make sure we have the active chats ui loaded and accessible + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + while(self.activeChats == nil) + usleep(100000); + dispatch_async(dispatch_get_main_queue(), ^{ + //remove everything from our view queue (including currently displayed views) + //and add intro screens back to the queue, if needed, followed by the view handling the xmpp uri action + [self.activeChats resetViewQueue]; + [self.activeChats dismissCompleteViewChainWithAnimation:NO andCompletion:^{ + [self.activeChats segueToIntroScreensIfNeeded]; + + BOOL registerNeeded = [MLXMPPManager sharedInstance].connectedXMPP.count == 0; + NSURLComponents* components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + DDLogVerbose(@"URI path '%@'", components.path); + DDLogVerbose(@"URI query '%@'", components.query); + + NSString* jid = components.path; + NSDictionary* jidParts = [HelperTools splitJid:jid]; + BOOL isRegister = NO; + BOOL isRoster = NO; + BOOL isMucJoin = NO; + BOOL isIbr = NO; + NSString* preauthToken = nil; + NSMutableDictionary* omemoFingerprints = [NSMutableDictionary new]; + //someone had the really superior (NOT!) idea to split uri query parts by ';' instead of the standard '&' + //making all existing uri libs useless, see: https://xmpp.org/extensions/xep-0147.html + //blame this author: Peter Saint-Andre + NSArray* queryItems = [components.query componentsSeparatedByString:@";"]; + for(NSString* item in queryItems) + { + NSArray* itemParts = [item componentsSeparatedByString:@"="]; + NSString* name = itemParts[0]; + NSString* value = @""; + if([itemParts count] > 1) + value = itemParts[1]; + DDLogVerbose(@"URI part '%@' = '%@'", name, value); + if([name isEqualToString:@"register"]) + isRegister = YES; + if([name isEqualToString:@"roster"]) + isRoster = YES; + if([name isEqualToString:@"join"]) + isMucJoin = YES; + if([name isEqualToString:@"ibr"] && [value isEqualToString:@"y"]) + isIbr = YES; + if([name isEqualToString:@"preauth"]) + preauthToken = [value copy]; + if([name hasPrefix:@"omemo-sid-"]) + { + NSNumber* sid = [NSNumber numberWithUnsignedInteger:(NSUInteger)[[name substringFromIndex:10] longLongValue]]; + NSData* fingerprint = [HelperTools signalIdentityWithHexKey:value]; + omemoFingerprints[sid] = fingerprint; + } + } + + if(!jidParts[@"host"]) + { + DDLogError(@"Ignoring xmpp: uri without host jid part!"); + return; + } + +#ifdef IS_QUICKSY + //make sure we hit the else below, even if (isRegister || (isRoster && registerNeeded)) == YES + if(NO) + ; +#else + if(isRegister || (isRoster && registerNeeded)) + { + NSString* username = nilDefault(jidParts[@"node"], @""); + NSString* host = jidParts[@"host"]; + + if(isRoster) + { + //isRoster variant does not specify a predefined username for the new account, register does (but this is still optional) + username = @""; + //isRoster variant without ibr does not specify a host to register on, too + if(!isIbr) + host = @""; + } + + //show register view and, if isRoster, add contact as usual after register (e.g. call this method again) + weakify(self); + [self.activeChats showRegisterWithUsername:username onHost:host withToken:preauthToken usingCompletion:^(NSNumber* accountID) { + strongify(self); + DDLogVerbose(@"Got accountID for newly registered account: %@", accountID); + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:accountID]; + DDLogInfo(@"Got newly registered account: %@", account); + + //this should never happen + MLAssert(account != nil, @"Can not use account after register!", (@{ + @"components": components, + @"username": username, + @"host": host, + })); + + //add given jid to our roster if in roster mode (e.g. the jid is not the jid we just registered as like in register mode) + if(account != nil && isRoster) //silence memory warning despite assertion above + return [self handleXMPPURL:url]; + }]; + } +#endif + //I know this if is moot, but I wanted to preserve the different cases: + //either we already have one or more accounts and the xmpp: uri is of type subscription (ibr does not matter here, + //because we already have an account) or muc join + //OR the xmpp: uri is a normal xmpp uri having only a jid we should add as our new contact (preauthToken will be nil in this case) + else if((!registerNeeded && (isRoster || isMucJoin)) || !registerNeeded) + { + if([MLXMPPManager sharedInstance].connectedXMPP.count == 1) + { + //the add contacts ui will check if the contact is already present on the selected account + xmpp* account = [[MLXMPPManager sharedInstance].connectedXMPP firstObject]; + [self.activeChats showAddContactWithJid:jid preauthToken:preauthToken prefillAccount:account andOmemoFingerprints:omemoFingerprints]; + } + else + //the add contacts ui will check if the contact is already present on the selected account + [self.activeChats showAddContactWithJid:jid preauthToken:preauthToken prefillAccount:nil andOmemoFingerprints:omemoFingerprints]; + } + else + { + DDLogError(@"No account available to handel xmpp: uri!"); + + UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error adding contact or channel", @"") message:NSLocalizedString(@"No account available to handel 'xmpp:' URI!", @"") preferredStyle:UIAlertControllerStyleAlert]; + [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + }]]; + [self.activeChats presentViewController:messageAlert animated:YES completion:nil]; + } + }]; + }); + }); +} + +-(BOOL) application:(UIApplication*) app openURL:(NSURL*) url options:(NSDictionary*) options +{ + DDLogInfo(@"Got openURL for '%@' with options: %@", url, options); + if([url.scheme isEqualToString:@"xmpp"]) //for xmpp uris + { + [self handleXMPPURL:url]; + return YES; + } + else if([url.scheme isEqualToString:kMonalOpenURL.scheme]) //app opened via sharesheet + { + //make sure our outbox content is sent (if the mainapp is still connected and also was in foreground while the sharesheet was used) + //and open the chat the newest outbox entry was sent to + //make sure activechats ui is properly initialized when calling this + createQueuedTimer(0.5, dispatch_get_main_queue(), (^{ + DDLogInfo(@"Got %@ url, trying to send all outboxes...", kMonalOpenURL); + [self sendAllOutboxes]; + })); + return YES; + } + return NO; +} + +#pragma mark - user notifications + +-(void) application:(UIApplication*) application didReceiveRemoteNotification:(NSDictionary*) userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result)) completionHandler +{ + DDLogVerbose(@"got didReceiveRemoteNotification: %@", userInfo); + [self incomingWakeupWithCompletionHandler:completionHandler]; +} + +-(void) userNotificationCenter:(UNUserNotificationCenter*) center willPresentNotification:(UNNotification*) notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options)) completionHandler +{ + DDLogInfo(@"userNotificationCenter:willPresentNotification:withCompletionHandler called"); + //show local notifications while the app is open and ignore remote pushes + if([notification.request.trigger isKindOfClass:[UNPushNotificationTrigger class]]) { + completionHandler(UNNotificationPresentationOptionNone); + } else { + completionHandler(UNNotificationPresentationOptionList | UNNotificationPresentationOptionBanner); + } +} + +-(void) userNotificationCenter:(UNUserNotificationCenter*) center didReceiveNotificationResponse:(UNNotificationResponse*) response withCompletionHandler:(void (^)(void)) completionHandler +{ + if([response.notification.request.content.categoryIdentifier isEqualToString:@"message"]) + { + DDLogVerbose(@"notification action '%@' triggered for %@", response.actionIdentifier, response.notification.request.content.userInfo); + MLContact* fromContact = [MLContact createContactFromJid:response.notification.request.content.userInfo[@"fromContactJid"] andAccountID:response.notification.request.content.userInfo[@"fromContactAccountID"]]; + MLAssert(fromContact, @"fromContact should not be nil"); + NSString* messageId = response.notification.request.content.userInfo[@"messageId"]; + MLAssert(messageId, @"messageId should not be nil"); + xmpp* account = fromContact.account; + //this can happen if that account got disabled + if(account == nil) + { + //call completion handler directly (we did not handle anything and no connectIfNecessary was called) + if(completionHandler) + completionHandler(); + return; + } + + //add our completion handler to handler queue + [self incomingWakeupWithCompletionHandler:^(UIBackgroundFetchResult result __unused) { + completionHandler(); + }]; + + + //make sure we have an active buddy for this chat + [[DataLayer sharedInstance] addActiveBuddies:fromContact.contactJid forAccount:fromContact.accountID]; + + //handle message actions + if([response.actionIdentifier isEqualToString:@"REPLY_ACTION"]) + { + DDLogInfo(@"REPLY_ACTION triggered..."); + UNTextInputNotificationResponse* textResponse = (UNTextInputNotificationResponse*) response; + if(!textResponse.userText.length) + { + DDLogWarn(@"User tried to send empty text response!"); + return; + } + + //mark messages as read because we are replying + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:fromContact.contactJid andAccount:fromContact.accountID tillStanzaId:messageId wasOutgoing:NO]; + DDLogDebug(@"Marked as read: %@", unread); + + //remove notifications of all read messages (this will cause the MLNotificationManager to update the app badge, too) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:account userInfo:@{@"messagesArray":unread}]; + + //update unread count in active chats list + [fromContact refresh]; //this will make sure the unread count is correct + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": fromContact + }]; + + BOOL encrypted = [[DataLayer sharedInstance] shouldEncryptForJid:fromContact.contactJid andAccountID:fromContact.accountID]; + [[MLXMPPManager sharedInstance] sendMessageAndAddToHistory:textResponse.userText havingType:kMessageTypeText toContact:fromContact isEncrypted:encrypted uploadInfo:nil withCompletionHandler:^(BOOL successSendObject, NSString* messageIdSentObject) { + DDLogInfo(@"REPLY_ACTION success=%@, messageIdSentObject=%@", bool2str(successSendObject), messageIdSentObject); + }]; + } + else if([response.actionIdentifier isEqualToString:@"MARK_AS_READ_ACTION"]) + { + DDLogInfo(@"MARK_AS_READ_ACTION triggered..."); + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:fromContact.contactJid andAccount:fromContact.accountID tillStanzaId:messageId wasOutgoing:NO]; + DDLogDebug(@"Marked as read: %@", unread); + + //publish MDS display marker and optionally send displayed marker for last unread message (XEP-0333) + DDLogDebug(@"Sending MDS (and possibly XEP-0333 displayed marker) for messages: %@", unread); + [account sendDisplayMarkerForMessages:unread]; + + //remove notifications of all read messages (this will cause the MLNotificationManager to update the app badge, too) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:account userInfo:@{@"messagesArray":unread}]; + + //update unread count in active chats list + [fromContact refresh]; //this will make sure the unread count is correct + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:account userInfo:@{ + @"contact": fromContact + }]; + } + else if([response.actionIdentifier isEqualToString:@"com.apple.UNNotificationDefaultActionIdentifier"]) //open chat of this contact + [self openChatOfContact:fromContact]; + } + else if([response.notification.request.content.categoryIdentifier isEqualToString:@"subscription"]) + { + DDLogVerbose(@"notification action '%@' triggered for %@", response.actionIdentifier, response.notification.request.content.userInfo); + MLContact* fromContact = [MLContact createContactFromJid:response.notification.request.content.userInfo[@"fromContactJid"] andAccountID:response.notification.request.content.userInfo[@"fromContactAccountID"]]; + MLAssert(fromContact, @"fromContact should not be nil"); + xmpp* account = fromContact.account; + //this can happen if that account got disabled + if(account == nil) + { + //call completion handler directly (we did not handle anything and no connectIfNecessary was called) + if(completionHandler) + completionHandler(); + return; + } + + //add our completion handler to handler queue + [self incomingWakeupWithCompletionHandler:^(UIBackgroundFetchResult result __unused) { + completionHandler(); + }]; + + //handle subscription actions + if([response.actionIdentifier isEqualToString:@"APPROVE_SUBSCRIPTION_ACTION"]) + { + DDLogInfo(@"APPROVE_SUBSCRIPTION_ACTION triggered..."); + [[MLXMPPManager sharedInstance] addContact:fromContact]; + [self openChatOfContact:fromContact]; + } + else if([response.actionIdentifier isEqualToString:@"DENY_SUBSCRIPTION_ACTION"] || [response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) + { + DDLogInfo(@"DENY_SUBSCRIPTION_ACTION triggered..."); + [[MLXMPPManager sharedInstance] removeContact:fromContact]; + } + else if([response.actionIdentifier isEqualToString:@"com.apple.UNNotificationDefaultActionIdentifier"]) //open chat of this contact + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + while(self.activeChats == nil) + usleep(100000); + dispatch_async(dispatch_get_main_queue(), ^{ + [(ActiveChatsViewController*)self.activeChats showAddContact]; + }); + }); + } + else + { + //call completion handler directly (we did not handle anything and no connectIfNecessary was called) + if(completionHandler) + completionHandler(); + } +} + +-(void) userNotificationCenter:(UNUserNotificationCenter*) center openSettingsForNotification:(UNNotification*) notification +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + while(self.activeChats == nil) + usleep(100000); + dispatch_async(dispatch_get_main_queue(), ^{ + [(ActiveChatsViewController*)self.activeChats showNotificationSettings]; + }); + }); +} + +-(void) openChatOfContact:(MLContact* _Nullable) contact +{ + return [self openChatOfContact:contact withCompletion:nil]; +} + +-(void) openChatOfContact:(MLContact* _Nullable) contact withCompletion:(monal_id_block_t _Nullable) completion +{ + if(contact != nil) + _contactToOpen = contact; + if(completion != nil) + _completionToCall = completion; + + if(self.activeChats != nil && _contactToOpen != nil) + { + // the timer makes sure the view is properly initialized when opning the chat + createQueuedTimer(0.5, dispatch_get_main_queue(), (^{ + if(self->_contactToOpen != nil) + { + DDLogDebug(@"Opening chat for contact %@", [contact contactJid]); + // open new chat + [(ActiveChatsViewController*)self.activeChats presentChatWithContact:self->_contactToOpen andCompletion:self->_completionToCall]; + } + else + DDLogDebug(@"_contactToOpen changed to nil, not opening chat for contact %@", [contact contactJid]); + self->_contactToOpen = nil; + self->_completionToCall = nil; + })); + } + else + DDLogDebug(@"Not opening chat for contact %@", [contact contactJid]); +} + +-(UIInterfaceOrientationMask) application:(UIApplication*) application supportedInterfaceOrientationsForWindow:(UIWindow*) window +{ + return self.orientationLock; +} + +#pragma mark - memory +-(void) applicationDidReceiveMemoryWarning:(UIApplication*) application +{ + DDLogWarn(@"Got memory warning!"); +} + +#pragma mark - backgrounding + +-(void) startBackgroundTimer:(double) timeout +{ + //cancel old background timer if still running and start a new one + //this timer will fire after timeout seconds in background and disconnect gracefully (e.g. when fully idle the next time) + if(_backgroundTimer) + _backgroundTimer(); + _backgroundTimer = createTimer(timeout, ^{ + //mark timer as *not* running + self->_backgroundTimer = nil; + //retry background check (now handling idle state because no running background timer is blocking it) + dispatch_async(dispatch_get_main_queue(), ^{ + [self checkIfBackgroundTaskIsStillNeeded]; + }); + }); +} + +-(void) stopBackgroundTimer +{ + if(_backgroundTimer) + _backgroundTimer(); + _backgroundTimer = nil; + + //stop bg processing/refreshing tasks (we are foregrounded now) + //this will prevent scenarious where one of these tasks times out after the user puts the app into background again + //in this case a possible syncError notification would be suppressed in checkIfBackgroundTaskIsStillNeeded + //but since the user openend the app, we want these errors not being suppressed + @synchronized(self) { + if(self->_bgProcessing != nil) + { + DDLogDebug(@"Stopping bg processing task, we are foregrounded now"); + [DDLog flushLog]; + BGTask* task = self->_bgProcessing; + self->_bgProcessing = nil; + [task setTaskCompletedWithSuccess:YES]; + return; + } + } + @synchronized(self) { + if(self->_bgRefreshing != nil) + { + DDLogDebug(@"Stopping bg refreshing task, we are foregrounded now"); + [DDLog flushLog]; + BGTask* task = self->_bgRefreshing; + self->_bgRefreshing = nil; + [task setTaskCompletedWithSuccess:YES]; + return; + } + } +} + +-(UIViewController*) getTopViewController +{ + UIViewController* topViewController = self.window.rootViewController; + while(topViewController.presentedViewController) + topViewController = topViewController.presentedViewController; + return topViewController; +} + +-(void) prepareForFreeze:(NSNotification*) notification +{ + for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP) + [account freeze]; + [MLProcessLock unlock]; + _wasFreezed = YES; + @synchronized(self) { + DDLogVerbose(@"Setting _shutdownPending to NO..."); + _shutdownPending = NO; + } +} + +-(void) applicationWillEnterForeground:(UIApplication*) application +{ + DDLogInfo(@"Entering FG"); + [MLProcessLock lock]; + + @synchronized(self) { + DDLogVerbose(@"Setting _shutdownPending to NO..."); + _shutdownPending = NO; + } + + //only show loading HUD if we really got freezed before + MBProgressHUD* loadingHUD; + if(_wasFreezed) + { + loadingHUD = [MBProgressHUD showHUDAddedTo:[self getTopViewController].view animated:YES]; + loadingHUD.label.text = NSLocalizedString(@"Refreshing...", @""); + loadingHUD.mode = MBProgressHUDModeIndeterminate; + loadingHUD.removeFromSuperViewOnHide = YES; + + _wasFreezed = NO; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + //make sure the progress HUD is displayed before freezing the main thread + //only proceed with foregrounding if the NotificationServiceExtension is not running + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + if([MLProcessLock checkRemoteRunning:@"NotificationServiceExtension"]) + { + DDLogInfo(@"NotificationServiceExtension is running, waiting for its termination"); + [MLProcessLock waitForRemoteTermination:@"NotificationServiceExtension" withLoopHandler:^{ + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + }]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + //cancel already running background timer, we are now foregrounded again + [self stopBackgroundTimer]; + + [self addBackgroundTask]; + [[MLXMPPManager sharedInstance] nowForegrounded]; //NOTE: this will unfreeze all queues in our accounts + + //open call ui using first call if at least one call is present + NSDictionary* activeCalls = [self.voipProcessor getActiveCalls]; + for(NSUUID* uuid in activeCalls) + { + [self.activeChats presentCall:activeCalls[uuid]]; + break; + } + + //trigger view updates (this has to be done because the NotificationServiceExtension could have updated the database some time ago) + //this must be done *after* [[MLXMPPManager sharedInstance] nowForegrounded] to make sure an already open chat view + //knows it is now foregrounded (we obviously don't mark messages as read if a chat view is in background while still loaded/"visible") + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + + if(loadingHUD != nil) + loadingHUD.hidden = YES; + }); + }); +} + +-(void) nowReallyBackgrounded +{ + [self addBackgroundTask]; + [[MLXMPPManager sharedInstance] nowBackgrounded]; + [self startBackgroundTimer:GRACEFUL_TIMEOUT]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self checkIfBackgroundTaskIsStillNeeded]; + }); +} + +-(void) applicationDidEnterBackground:(UIApplication*) application +{ + UIApplicationState state = [application applicationState]; + if(state == UIApplicationStateInactive) + DDLogInfo(@"Screen lock / incoming call"); + else if(state == UIApplicationStateBackground) + DDLogInfo(@"Entering BG"); + + [self updateUnread]; +#if TARGET_OS_MACCATALYST + if(NSProcessInfo.processInfo.isLowPowerModeEnabled) + { + DDLogInfo(@"LowPowerMode is active: nowReallyBackgrounded to reduce power consumption"); + [self nowReallyBackgrounded]; + } + else + [[MLXMPPManager sharedInstance] noLongerInFocus]; +#else + [self nowReallyBackgrounded]; +#endif +} + +-(void) applicationWillTerminate:(UIApplication *)application +{ + @synchronized(self) { + DDLogVerbose(@"Setting _shutdownPending to YES..."); + _shutdownPending = YES; + DDLogWarn(@"|~~| T E R M I N A T I N G |~~|"); + [HelperTools scheduleBackgroundTask:YES]; //make sure delivery will be attempted, if needed (force as soon as possible) + DDLogInfo(@"|~~| 33%% |~~|"); + [[MLXMPPManager sharedInstance] nowBackgrounded]; + DDLogInfo(@"|~~| 66%% |~~|"); + [HelperTools updateSyncErrorsWithDeleteOnly:NO andWaitForCompletion:YES]; + DDLogInfo(@"|~~| 99%% |~~|"); + [[MLXMPPManager sharedInstance] disconnectAll]; + DDLogInfo(@"|~~| T E R M I N A T E D |~~|"); + [DDLog flushLog]; + } +} + +#pragma mark - error feedback + +-(void) showConnectionStatus:(NSNotification*) notification +{ + //this will show an error banner but only if our app is foregrounded + DDLogWarn(@"Got xmpp error %@", notification); + if(![HelperTools isNotInFocus]) + { + dispatch_async(dispatch_get_main_queue(), ^{ + xmpp* xmppAccount = notification.object; + //ignore errors with unknown accounts + //(possibly meaning an account we currently try to create --> the creating ui will take care of this already) + if(xmppAccount == nil) + return; + if(![notification.userInfo[@"isSevere"] boolValue]) + DDLogError(@"Minor XMPP Error(%@): %@", xmppAccount.connectionProperties.identity.jid, notification.userInfo[@"message"]); + NotificationBanner* banner = [[NotificationBanner alloc] initWithTitle:xmppAccount.connectionProperties.identity.jid subtitle:notification.userInfo[@"message"] leftView:nil rightView:nil style:([notification.userInfo[@"isSevere"] boolValue] ? BannerStyleDanger : BannerStyleWarning) colors:nil]; + banner.duration = 10.0; //show for 10 seconds to make sure users can read it + NotificationBannerQueue* queue = [[NotificationBannerQueue alloc] initWithMaxBannersOnScreenSimultaneously:2]; + [banner showWithQueuePosition:QueuePositionBack bannerPosition:BannerPositionTop queue:queue on:nil]; + }); + } + else + DDLogWarn(@"Not showing error banner: app not in focus!"); +} + +#pragma mark - mac menu +-(void) buildMenuWithBuilder:(id) builder +{ + [super buildMenuWithBuilder:builder]; + //monal + UIKeyCommand* preferencesCommand = [UIKeyCommand commandWithTitle:@"Preferences..." image:nil action:@selector(showSettings) input:@"," modifierFlags:UIKeyModifierCommand propertyList:nil]; + + UIMenu* preferencesMenu = [UIMenu menuWithTitle:@"" image:nil identifier:@"im.monal.preferences" options:UIMenuOptionsDisplayInline children:@[preferencesCommand]]; + [builder insertSiblingMenu:preferencesMenu afterMenuForIdentifier:UIMenuAbout]; + + //file + UIKeyCommand* newCommand = [UIKeyCommand commandWithTitle:@"New Message" image:nil action:@selector(showNew) input:@"N" modifierFlags:UIKeyModifierCommand propertyList:nil]; + + UIMenu* newMenu = [UIMenu menuWithTitle:@"" image:nil identifier:@"im.monal.new" options:UIMenuOptionsDisplayInline children:@[newCommand]]; + [builder insertChildMenu:newMenu atStartOfMenuForIdentifier:UIMenuFile]; + + UIKeyCommand* detailsCommand = [UIKeyCommand commandWithTitle:@"Details..." image:nil action:@selector(showDetails) input:@"I" modifierFlags:UIKeyModifierCommand propertyList:nil]; + + UIMenu* detailsMenu = [UIMenu menuWithTitle:@"" image:nil identifier:@"im.monal.detail" options:UIMenuOptionsDisplayInline children:@[detailsCommand]]; + [builder insertSiblingMenu:detailsMenu afterMenuForIdentifier:@"im.monal.new"]; + + UIKeyCommand* deleteCommand = [UIKeyCommand commandWithTitle:@"Delete Conversation" image:nil action:@selector(deleteConversation) input:@"\b" modifierFlags:UIKeyModifierCommand propertyList:nil]; + + UIMenu* deleteMenu = [UIMenu menuWithTitle:@"" image:nil identifier:@"im.monal.delete" options:UIMenuOptionsDisplayInline children:@[deleteCommand]]; + [builder insertSiblingMenu:deleteMenu afterMenuForIdentifier:@"im.monal.detail"]; + + [builder removeMenuForIdentifier:UIMenuHelp]; + + [builder replaceChildrenOfMenuForIdentifier:UIMenuAbout fromChildrenBlock:^NSArray * _Nonnull(NSArray * _Nonnull items) { + UICommand* itemCommand = (UICommand*)items.firstObject; + UICommand* aboutCommand = [UICommand commandWithTitle:itemCommand.title image:nil action:@selector(aboutWindow) propertyList:nil]; + NSArray* menuItems = @[aboutCommand]; + return menuItems; + }]; +} + +-(void) aboutWindow +{ + UIStoryboard* settingStoryBoard = [UIStoryboard storyboardWithName:@"Settings" bundle:nil]; + MLSettingsAboutViewController* settingAboutViewController = [settingStoryBoard instantiateViewControllerWithIdentifier:@"SettingsAboutViewController"]; + UINavigationController* navigationController = [[UINavigationController alloc] initWithRootViewController:settingAboutViewController]; + [self.window.rootViewController presentViewController:navigationController animated:NO completion:nil]; +} + +-(void) showNew +{ + [self.activeChats showContacts]; +} + +-(void) deleteConversation +{ + [self.activeChats deleteConversation]; +} + +-(void) showSettings +{ + [self.activeChats showSettings]; +} + +-(void) showDetails +{ + [self.activeChats showDetails]; +} + +#pragma mark - background tasks + +-(void) handleSpinner +{ + //show/hide spinner (dispatch *async* to main queue to allow for ui changes) + dispatch_async(dispatch_get_main_queue(), ^{ + if(([[MLXMPPManager sharedInstance] allAccountsIdle] && [MLFiletransfer isIdle])) + [self.activeChats.spinner stopAnimating]; + else + [self.activeChats.spinner startAnimating]; + }); +} + +-(void) nowNotIdle:(NSNotification*) notification +{ + DDLogInfo(@"### SOME ACCOUNT CHANGED TO NON-IDLE STATE ###"); + [self handleSpinner]; +} + +-(void) nowIdle:(NSNotification*) notification +{ + DDLogInfo(@"### SOME ACCOUNT CHANGED TO IDLE STATE ###"); + [self handleSpinner]; + + //dispatch *async* to main queue to avoid deadlock between receiveQueue ---sync--> im.monal.disconnect ---sync--> receiveQueue + dispatch_async(dispatch_get_main_queue(), ^{ + [self checkIfBackgroundTaskIsStillNeeded]; + }); +} + +-(void) filetransfersNowIdle:(NSNotification*) notification +{ + DDLogInfo(@"### FILETRANSFERS CHANGED TO IDLE STATE ###"); + //dispatch *async* to main queue to avoid deadlock between receiveQueue ---sync--> im.monal.disconnect ---sync--> receiveQueue + dispatch_async(dispatch_get_main_queue(), ^{ + [self checkIfBackgroundTaskIsStillNeeded]; + }); +} + +//this method will either be called from an anonymous timer thread or from the main thread +-(void) checkIfBackgroundTaskIsStillNeeded +{ + if([[MLXMPPManager sharedInstance] allAccountsIdle] && [MLFiletransfer isIdle]) + { + DDLogInfo(@"### ALL ACCOUNTS IDLE AND FILETRANSFERS COMPLETE NOW ###"); + + //if we used a bg fetch/processing task, that means we did not get a push informing us about a waiting message + //nor did the user interact with our app --> don't show possible sync warnings in this case (but delete old warnings if we are synced now) + [HelperTools updateSyncErrorsWithDeleteOnly:(self->_bgProcessing != nil || self->_bgRefreshing != nil) andWaitForCompletion:YES]; + + //use a synchronized block to disconnect only once + @synchronized(self) { + if(_backgroundTimer != nil || [_wakeupCompletions count] > 0 || _voipProcessor.pendingCallsCount > 0) + { + DDLogInfo(@"### ignoring idle state because background timer or wakeup completion timers or pending calls are still running ###"); + return; + } + if(_shutdownPending) + { + DDLogInfo(@"### ignoring idle state because a shutdown is already pending ###"); + return; + } + + DDLogInfo(@"### checking if background is still needed ###"); + BOOL background = [HelperTools isInBackground]; + if(background) + { + DDLogInfo(@"### All accounts idle, disconnecting and stopping all background tasks ###"); + [DDLog flushLog]; + DDLogVerbose(@"Setting _shutdownPending to YES..."); + _shutdownPending = YES; + [[MLXMPPManager sharedInstance] disconnectAll]; //disconnect all accounts to prevent TCP buffer leaking + [HelperTools scheduleBackgroundTask:NO]; //request bg fetch execution in BGFETCH_DEFAULT_INTERVAL seconds + [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ + BOOL stopped = NO; + //make sure this will be done only once, even if we have an uikit bgtask and a bg fetch running simultaneously + if(self->_bgTask != UIBackgroundTaskInvalid || self->_bgProcessing != nil || self->_bgRefreshing != nil) + { + //notify about pending app freeze (don't queue this notification because it should be handled IMMEDIATELY and INLINE) + DDLogVerbose(@"Posting kMonalWillBeFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalWillBeFreezed object:nil]; + } + if(self->_bgTask != UIBackgroundTaskInvalid) + { + DDLogDebug(@"stopping UIKit _bgTask"); + [DDLog flushLog]; + UIBackgroundTaskIdentifier task = self->_bgTask; + self->_bgTask = UIBackgroundTaskInvalid; + [[UIApplication sharedApplication] endBackgroundTask:task]; + stopped = YES; + } + if(self->_bgProcessing != nil) + { + DDLogDebug(@"stopping backgroundProcessingTask"); + [DDLog flushLog]; + BGTask* task = self->_bgProcessing; + self->_bgProcessing = nil; + [task setTaskCompletedWithSuccess:YES]; + stopped = YES; + } + if(self->_bgRefreshing != nil) + { + DDLogDebug(@"stopping backgroundRefreshingTask"); + [DDLog flushLog]; + BGTask* task = self->_bgRefreshing; + self->_bgRefreshing = nil; + [task setTaskCompletedWithSuccess:YES]; + stopped = YES; + } + if(!stopped) + { + DDLogDebug(@"no background tasks running, nothing to stop"); + [DDLog flushLog]; + } + else + { + DDLogVerbose(@"Posting kMonalIsFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalIsFreezed object:nil]; + [HelperTools flushLogsWithTimeout:0.100]; + } + }]; + } + } + } +} + +-(void) addBackgroundTask +{ + [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ + //don't start uikit bg task if it's already running + if(self->_bgTask != UIBackgroundTaskInvalid) + DDLogVerbose(@"Not starting UIKit background task, already running: %d", (int)self->_bgTask); + else + { + DDLogInfo(@"Starting UIKit background task..."); + //indicate we want to do work even if the app is put into background + self->_bgTask = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:^(void) { + DDLogWarn(@"BG WAKE EXPIRING"); + [DDLog flushLog]; + + @synchronized(self) { + //ui background tasks expire at the same time as background processing/refreshing tasks + //--> we have to check if a background processing/refreshing task is running and don't disconnect, if so + BOOL stopped = NO; + if(self->_bgProcessing == nil && self->_bgRefreshing == nil) + { + DDLogVerbose(@"Setting _shutdownPending to YES..."); + self->_shutdownPending = YES; + DDLogDebug(@"_bgProcessing == nil && _bgRefreshing == nil --> disconnecting and ending background task"); + + //this has to be before account disconnects, to detect which accounts are not idle (e.g. have a sync error) + [HelperTools updateSyncErrorsWithDeleteOnly:NO andWaitForCompletion:YES]; + + //disconnect all accounts to prevent TCP buffer leaking + [[MLXMPPManager sharedInstance] disconnectAll]; + + //schedule a BGProcessingTaskRequest to process this further as soon as possible + //(if we end up here, the graceful shuttdown did not work out because we are not idle --> we need more cpu time) + [HelperTools scheduleBackgroundTask:YES]; //force as soon as possible + + //notify about pending app freeze (don't queue this notification because it should be handled IMMEDIATELY and INLINE) + DDLogVerbose(@"Posting kMonalWillBeFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalWillBeFreezed object:nil]; + + stopped = YES; + } + else + DDLogDebug(@"_bgProcessing != nil || _bgRefreshing != nil --> not disconnecting"); + + DDLogDebug(@"stopping UIKit _bgTask"); + [DDLog flushLog]; + UIBackgroundTaskIdentifier task = self->_bgTask; + self->_bgTask = UIBackgroundTaskInvalid; + [[UIApplication sharedApplication] endBackgroundTask:task]; + + if(stopped) + { + DDLogVerbose(@"Posting kMonalIsFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalIsFreezed object:nil]; + [HelperTools flushLogsWithTimeout:0.100]; + } + } + }]; + } + }]; +} + +-(void) handleBackgroundProcessingTask:(BGTask*) task +{ + DDLogInfo(@"RUNNING BGPROCESSING SETUP HANDLER"); + + _bgProcessing = task; + weakify(task); + task.expirationHandler = ^{ + strongify(task); + DDLogWarn(@"*** BGPROCESSING EXPIRED ***"); + [DDLog flushLog]; + + DDLogVerbose(@"Dispatching to main queue..."); + [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ + BOOL background = [HelperTools isInBackground]; + DDLogVerbose(@"Waiting for @synchronized(self)..."); + @synchronized(self) { + DDLogVerbose(@"Now entered @synchronized(self) block..."); + //ui background tasks expire at the same time as background fetching tasks + //--> we have to check if an ui bg task is running and don't disconnect, if so + BOOL stopped = NO; + if(background && self->_voipProcessor.pendingCallsCount == 0 && self->_bgTask == UIBackgroundTaskInvalid) + { + DDLogVerbose(@"Setting _shutdownPending to YES..."); + self->_shutdownPending = YES; + DDLogDebug(@"_bgTask == UIBackgroundTaskInvalid --> disconnecting and ending background task"); + + //this has to be before account disconnects, to detect which accounts are not idle (e.g. have a sync error) + [HelperTools updateSyncErrorsWithDeleteOnly:YES andWaitForCompletion:YES]; + + //disconnect all accounts to prevent TCP buffer leaking + [[MLXMPPManager sharedInstance] disconnectAll]; + + //schedule a new BGProcessingTaskRequest to process this further as soon as possible + //(if we end up here, the graceful shuttdown did not work out because we are not idle --> we need more cpu time) + [HelperTools scheduleBackgroundTask:YES]; //force as soon as possible + + //notify about pending app freeze (don't queue this notification because it should be handled IMMEDIATELY and INLINE) + DDLogVerbose(@"Posting kMonalWillBeFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalWillBeFreezed object:nil]; + + stopped = YES; + } + else + DDLogDebug(@"!background || _bgTask != UIBackgroundTaskInvalid --> not disconnecting"); + + DDLogDebug(@"stopping backgroundProcessingTask: %@", task); + [DDLog flushLog]; + self->_bgProcessing = nil; + //only signal success, if we are not in background anymore (otherwise we *really* expired without being idle) + [task setTaskCompletedWithSuccess:!background]; + + if(stopped) + { + DDLogVerbose(@"Posting kMonalIsFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalIsFreezed object:nil]; + [HelperTools flushLogsWithTimeout:0.100]; + } + } + }]; + }; + + //only proceed with our BGTASK if the NotificationServiceExtension is not running + [MLProcessLock lock]; + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + if([MLProcessLock checkRemoteRunning:@"NotificationServiceExtension"]) + { + DDLogInfo(@"NotificationServiceExtension is running, waiting for its termination"); + [MLProcessLock waitForRemoteTermination:@"NotificationServiceExtension" withLoopHandler:^{ + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + }]; + } + + //we allow ui bgtasks alongside "modern" bgtasks to extend our runtime in case the "modern" background tasks only provde a few seconds of bgtime +// if(self->_bgTask != UIBackgroundTaskInvalid) +// { +// DDLogDebug(@"stopping UIKit _bgTask, not needed when running a bg task"); +// [DDLog flushLog]; +// UIBackgroundTaskIdentifier task = self->_bgTask; +// self->_bgTask = UIBackgroundTaskInvalid; +// [[UIApplication sharedApplication] endBackgroundTask:task]; +// } + + if(self->_bgRefreshing != nil) + { + DDLogDebug(@"stopping bg refreshing task, not needed when running a (longer running) bg processing task"); + [DDLog flushLog]; + BGTask* refreshingTask = self->_bgRefreshing; + self->_bgRefreshing = nil; + [refreshingTask setTaskCompletedWithSuccess:YES]; + } + + if(![[MLXMPPManager sharedInstance] hasConnectivity]) + DDLogError(@"BGTASK has *no* connectivity? That's strange!"); + + [self startBackgroundTimer:BGPROCESS_GRACEFUL_TIMEOUT]; + @synchronized(self) { + DDLogVerbose(@"Setting _shutdownPending to NO..."); + _shutdownPending = NO; + } + //don't use *self* connectIfNecessary, because we don't need an additional UIKit bg task, this one is already a bg task + [[MLXMPPManager sharedInstance] connectIfNecessary]; + + //request another execution in BGFETCH_DEFAULT_INTERVAL seconds + [HelperTools scheduleBackgroundTask:NO]; + + DDLogInfo(@"BGPROCESSING SETUP HANDLER COMPLETED SUCCESSFULLY..."); +} + +-(void) handleBackgroundRefreshingTask:(BGTask*) task +{ + DDLogInfo(@"RUNNING BGREFRESHING SETUP HANDLER"); + + _bgRefreshing = task; + weakify(task); + task.expirationHandler = ^{ + strongify(task); + DDLogWarn(@"*** BGREFRESHING EXPIRED ***"); + [DDLog flushLog]; + + DDLogVerbose(@"Dispatching to main queue..."); + [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ + BOOL background = [HelperTools isInBackground]; + DDLogVerbose(@"Waiting for @synchronized(self)..."); + @synchronized(self) { + DDLogVerbose(@"Now entered @synchronized(self) block..."); + //ui background tasks expire at the same time as background fetching tasks + //--> we have to check if an ui bg task is running and don't disconnect, if so + BOOL stopped = NO; + if(background && self->_voipProcessor.pendingCallsCount == 0 && self->_bgTask == UIBackgroundTaskInvalid) + { + DDLogVerbose(@"Setting _shutdownPending to YES..."); + self->_shutdownPending = YES; + DDLogDebug(@"_bgTask == UIBackgroundTaskInvalid --> disconnecting and ending background task"); + + //this has to be before account disconnects, to detect which accounts are not idle (e.g. have a sync error) + [HelperTools updateSyncErrorsWithDeleteOnly:YES andWaitForCompletion:YES]; + + //disconnect all accounts to prevent TCP buffer leaking + [[MLXMPPManager sharedInstance] disconnectAll]; + + //schedule a new BGProcessingTaskRequest to process this further as soon as possible + //(if we end up here, the graceful shuttdown did not work out because we are not idle --> we need more cpu time) + [HelperTools scheduleBackgroundTask:YES]; //force as soon as possible + + //notify about pending app freeze (don't queue this notification because it should be handled IMMEDIATELY and INLINE) + DDLogVerbose(@"Posting kMonalWillBeFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalWillBeFreezed object:nil]; + + stopped = YES; + } + else + DDLogDebug(@"!background || _bgTask != UIBackgroundTaskInvalid --> not disconnecting"); + + DDLogDebug(@"stopping backgroundProcessingTask: %@", task); + [DDLog flushLog]; + self->_bgRefreshing = nil; + //only signal success, if we are not in background anymore (otherwise we *really* expired without being idle) + [task setTaskCompletedWithSuccess:!background]; + + if(stopped) + { + DDLogVerbose(@"Posting kMonalIsFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalIsFreezed object:nil]; + [HelperTools flushLogsWithTimeout:0.100]; + } + } + }]; + }; + + //only proceed with our BGTASK if the NotificationServiceExtension is not running + [MLProcessLock lock]; + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + if([MLProcessLock checkRemoteRunning:@"NotificationServiceExtension"]) + { + DDLogInfo(@"NotificationServiceExtension is running, waiting for its termination"); + [MLProcessLock waitForRemoteTermination:@"NotificationServiceExtension" withLoopHandler:^{ + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + }]; + } + + //we allow ui bgtasks alongside "modern" bgtasks to extend our runtime in case the "modern" background tasks only provde a few seconds of bgtime +// if(self->_bgTask != UIBackgroundTaskInvalid) +// { +// DDLogDebug(@"stopping UIKit _bgTask, not needed when running a bg task"); +// [DDLog flushLog]; +// UIBackgroundTaskIdentifier task = self->_bgTask; +// self->_bgTask = UIBackgroundTaskInvalid; +// [[UIApplication sharedApplication] endBackgroundTask:task]; +// } + + if(![[MLXMPPManager sharedInstance] hasConnectivity]) + { + DDLogError(@"BGTASK has *no* connectivity? That's strange!"); + } + + [self startBackgroundTimer:GRACEFUL_TIMEOUT]; + @synchronized(self) { + DDLogVerbose(@"Setting _shutdownPending to NO..."); + _shutdownPending = NO; + } + //don't use *self* connectIfNecessary, because we don't need an additional UIKit bg task, this one is already a bg task + [[MLXMPPManager sharedInstance] connectIfNecessary]; + + //request another execution in BGFETCH_DEFAULT_INTERVAL seconds + [HelperTools scheduleBackgroundTask:NO]; + + DDLogInfo(@"BGREFRESHING SETUP HANDLER COMPLETED SUCCESSFULLY..."); +} + +-(void) configureBackgroundTasks +{ + [[BGTaskScheduler sharedScheduler] registerForTaskWithIdentifier:kBackgroundProcessingTask usingQueue:dispatch_get_main_queue() launchHandler:^(BGTask *task) { + DDLogDebug(@"RUNNING BGPROCESSING LAUNCH HANDLER"); + DDLogInfo(@"BG time available: %f", [UIApplication sharedApplication].backgroundTimeRemaining); + if(![HelperTools isInBackground]) + { + DDLogDebug(@"Already in foreground, stopping bgtask"); + [task setTaskCompletedWithSuccess:YES]; + return; + } + @synchronized(self) { + if(self->_bgProcessing != nil) + { + DDLogDebug(@"Already running a bg processing task, stopping second bg processing task"); + [task setTaskCompletedWithSuccess:YES]; + return; + } + } + [self handleBackgroundProcessingTask:task]; + }]; + + [[BGTaskScheduler sharedScheduler] registerForTaskWithIdentifier:kBackgroundRefreshingTask usingQueue:dispatch_get_main_queue() launchHandler:^(BGTask *task) { + DDLogDebug(@"RUNNING BGREFRESHING LAUNCH HANDLER"); + DDLogInfo(@"BG time available: %f", [UIApplication sharedApplication].backgroundTimeRemaining); + if(![HelperTools isInBackground]) + { + DDLogDebug(@"Already in foreground, stopping bgtask"); + [task setTaskCompletedWithSuccess:YES]; + return; + } + @synchronized(self) { + if(self->_bgProcessing != nil) + { + DDLogDebug(@"Already running bg processing task, stopping new bg refreshing task"); + [task setTaskCompletedWithSuccess:YES]; + return; + } + } + @synchronized(self) { + if(self->_bgRefreshing != nil) + { + DDLogDebug(@"Already running a bg refreshing task, stopping second bg refreshing task"); + [task setTaskCompletedWithSuccess:YES]; + return; + } + } + [self handleBackgroundRefreshingTask:task]; + }]; +} + +-(void) handleScheduleBackgroundTaskNotification:(NSNotification*) notification +{ + BOOL force = YES; + if(notification.userInfo) + force = [notification.userInfo[@"force"] boolValue]; + [HelperTools scheduleBackgroundTask:force]; +} + +-(void) connectIfNecessaryWithOptions:(NSDictionary*) options +{ + static NSUInteger applicationState; + static monal_void_block_t cancelEmergencyTimer; + static monal_void_block_t cancelCurrentTimer = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + applicationState = [UIApplication sharedApplication].applicationState; + cancelEmergencyTimer = createTimer(16.0, (^{ + DDLogError(@"Emergency: crashlogs are still blocking connect after 16 seconds, connecting anyways!"); + if(cancelCurrentTimer != nil) + cancelCurrentTimer(); + [MLXMPPManager sharedInstance].isConnectBlocked = NO; + [[MLXMPPManager sharedInstance] connectIfNecessary]; + })); + }); + //this method is called by didFinishLaunchingWithOptions: and our ipc handler (but this is currently unused) + //we block the reconnect while the crash reports have not been processed yet, to avoid a crash loop preventing + //the user from sending the crash report + int count = [HelperTools pendingCrashreportCount]; + if(count > 0 && options == nil && applicationState != UIApplicationStateBackground) + { + [MLXMPPManager sharedInstance].isConnectBlocked = YES; + DDLogWarn(@"Blocking connect of connectIfNecessary: crash reports still pending: %d, retrying in 1 second...", count); + cancelCurrentTimer = createTimer(1.0, (^{ [self connectIfNecessaryWithOptions:options]; })); + } + else + { + [MLXMPPManager sharedInstance].isConnectBlocked = NO; + DDLogInfo(@"Now unblocking connect of connectIfNecessary (applicationState%@UIApplicationStateBackground, count=%d, options=%@)...", + applicationState == UIApplicationStateBackground ? @"==" : @"!=", + count, + options + ); + cancelEmergencyTimer(); + } + [[MLXMPPManager sharedInstance] connectIfNecessary]; +} + +-(void) incomingWakeupWithCompletionHandler:(void (^)(UIBackgroundFetchResult result)) completionHandler +{ + if(![HelperTools isInBackground]) + { + DDLogWarn(@"Ignoring incomingWakeupWithCompletionHandler: because app is in FG!"); + completionHandler(UIBackgroundFetchResultNoData); + return; + } + //we need the wakeup completion handling even if a uikit bgtask or bgprocessing or bgrefreshing is running because we want to keep + //the connection for a few seconds to allow message receipts to come in instead of triggering the appex + + NSString* completionId = [[NSUUID UUID] UUIDString]; + DDLogInfo(@"got incomingWakeupWithCompletionHandler with ID %@", completionId); + + //only proceed with handling wakeup if the NotificationServiceExtension is not running + [MLProcessLock lock]; + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + if([MLProcessLock checkRemoteRunning:@"NotificationServiceExtension"]) + { + DDLogInfo(@"NotificationServiceExtension is running, waiting for its termination"); + [MLProcessLock waitForRemoteTermination:@"NotificationServiceExtension" withLoopHandler:^{ + [[IPC sharedInstance] sendMessage:@"Monal.disconnectAll" withData:nil to:@"NotificationServiceExtension"]; + }]; + } + + //don't use *self* connectIfNecessary] because we already have a background task here + //that gets stopped once we call the completionHandler + [[MLXMPPManager sharedInstance] connectIfNecessary]; + + //register push completion handler and associated timer (use the GRACEFUL_TIMEOUT here, too) + @synchronized(self) { + _wakeupCompletions[completionId] = @{ + @"handler": completionHandler, + @"timer": createTimer(GRACEFUL_TIMEOUT, (^{ + DDLogWarn(@"### Wakeup timer triggered for ID %@ ###", completionId); + dispatch_async(dispatch_get_main_queue(), ^{ + @synchronized(self) { + DDLogInfo(@"Handling wakeup completion %@", completionId); + BOOL background = [HelperTools isInBackground]; + + //we have to check if an ui bg task or background processing/refreshing task is running and don't disconnect, if so + BOOL stopped = NO; + if(background && self->_voipProcessor.pendingCallsCount == 0 && self->_bgTask == UIBackgroundTaskInvalid && self->_bgProcessing == nil && self->_bgRefreshing == nil) + { + DDLogVerbose(@"Setting _shutdownPending to YES..."); + self->_shutdownPending = YES; + DDLogDebug(@"background && _bgTask == UIBackgroundTaskInvalid && _bgProcessing == nil && _bgRefreshing == nil --> disconnecting and feeding wakeup completion"); + + //this has to be before account disconnects, to detect which accounts are/are not idle (e.g. don't have/have a sync error) + BOOL wasIdle = [[MLXMPPManager sharedInstance] allAccountsIdle] && [MLFiletransfer isIdle]; + [HelperTools updateSyncErrorsWithDeleteOnly:NO andWaitForCompletion:YES]; + + //disconnect all accounts to prevent TCP buffer leaking + [[MLXMPPManager sharedInstance] disconnectAll]; + + //schedule a new BGProcessingTaskRequest to process this further as soon as possible, if we are not idle + //(if we end up here, the graceful shuttdown did not work out because we are not idle --> we need more cpu time) + [HelperTools scheduleBackgroundTask:!wasIdle]; + + //notify about pending app freeze (don't queue this notification because it should be handled IMMEDIATELY and INLINE) + DDLogVerbose(@"Posting kMonalWillBeFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalWillBeFreezed object:nil]; + + stopped = YES; + } + else + DDLogDebug(@"NOT (background && _bgTask == UIBackgroundTaskInvalid && _bgProcessing == nil && _bgRefreshing == nil) --> not disconnecting"); + + //call completion (should be done *after* the idle state check because it could freeze the app) + DDLogInfo(@"Calling wakeup completion handler..."); + [DDLog flushLog]; + [self->_wakeupCompletions removeObjectForKey:completionId]; + completionHandler(UIBackgroundFetchResultFailed); + + if(stopped) + { + DDLogVerbose(@"Posting kMonalIsFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalIsFreezed object:nil]; + [HelperTools flushLogsWithTimeout:0.100]; + } + + //trigger disconnect if we are idle and no timer is blocking us now + if(self->_bgTask != UIBackgroundTaskInvalid || self->_bgProcessing != nil || self->_bgRefreshing != nil) + dispatch_async(dispatch_get_main_queue(), ^{ + [self checkIfBackgroundTaskIsStillNeeded]; + }); + } + }); + })) + }; + DDLogInfo(@"Added timer %@ to wakeup completion list...", completionId); + } +} + + +#pragma mark - share sheet added + +//send all sharesheet outboxes (this method will be called by AppDelegate if opened via monalOpen:// url) +-(void) sendAllOutboxes +{ + //delay outbox sending until we have an active chats ui + if(self.activeChats == nil) + { + createQueuedTimer(0.5, dispatch_get_main_queue(), (^{ + [self sendAllOutboxes]; + })); + return; + } + + [(ActiveChatsViewController*)self.activeChats dismissCompleteViewChainWithAnimation:YES andCompletion:^{ + //open the destination chat only once + for(NSDictionary* payload in [[DataLayer sharedInstance] getShareSheetPayload]) + { + DDLogInfo(@"Sending outbox entry: %@", payload); + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:payload[@"account_id"]]; + if(account == nil) + { + UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Sharing failed", @"") message:[NSString stringWithFormat:NSLocalizedString(@"Cannot share something with disabled/deleted account, destination: %@, internal account id: %@", @""), payload[@"recipient"], payload[@"account_id"]] preferredStyle:UIAlertControllerStyleAlert]; + [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + }]]; + [self.activeChats presentViewController:messageAlert animated:YES completion:nil]; + [[DataLayer sharedInstance] deleteShareSheetPayloadWithId:payload[@"id"]]; + continue; + } + MLContact* contact = [MLContact createContactFromJid:payload[@"recipient"] andAccountID:account.accountID]; + + monal_id_block_t cleanup = ^(NSDictionary* payload) { + [[DataLayer sharedInstance] deleteShareSheetPayloadWithId:payload[@"id"]]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + if(self.activeChats.currentChatView != nil) + { + [self.activeChats.currentChatView scrollToBottomAnimated:NO]; + [self.activeChats.currentChatView hideUploadHUD]; + } + //send next item (if there is one left) + [self sendAllOutboxes]; + }; + + monal_id_block_t sendItem = ^(id dummy __unused){ + BOOL encrypted = [[DataLayer sharedInstance] shouldEncryptForJid:contact.contactJid andAccountID:contact.accountID]; + if([payload[@"type"] isEqualToString:@"text"]) + { + [[MLXMPPManager sharedInstance] sendMessageAndAddToHistory:payload[@"data"] havingType:kMessageTypeText toContact:contact isEncrypted:encrypted uploadInfo:nil withCompletionHandler:^(BOOL successSendObject, NSString* messageIdSentObject) { + DDLogInfo(@"SHARESHEET_SEND_DATA success=%@, account=%@, messageIdSentObject=%@", bool2str(successSendObject), account.accountID, messageIdSentObject); + cleanup(payload); + }]; + } + else if([payload[@"type"] isEqualToString:@"url"]) + { + [[MLXMPPManager sharedInstance] sendMessageAndAddToHistory:payload[@"data"] havingType:kMessageTypeUrl toContact:contact isEncrypted:encrypted uploadInfo:nil withCompletionHandler:^(BOOL successSendObject, NSString* messageIdSentObject) { + DDLogInfo(@"SHARESHEET_SEND_DATA success=%@, account=%@, messageIdSentObject=%@", bool2str(successSendObject), account.accountID, messageIdSentObject); + cleanup(payload); + }]; + } + else if([payload[@"type"] isEqualToString:@"geo"]) + { + [[MLXMPPManager sharedInstance] sendMessageAndAddToHistory:payload[@"data"] havingType:kMessageTypeGeo toContact:contact isEncrypted:encrypted uploadInfo:nil withCompletionHandler:^(BOOL successSendObject, NSString* messageIdSentObject) { + DDLogInfo(@"SHARESHEET_SEND_DATA success=%@, account=%@, messageIdSentObject=%@", bool2str(successSendObject), account.accountID, messageIdSentObject); + cleanup(payload); + }]; + } + else if([payload[@"type"] isEqualToString:@"image"] || [payload[@"type"] isEqualToString:@"file"] || [payload[@"type"] isEqualToString:@"contact"] || [payload[@"type"] isEqualToString:@"audiovisual"]) + { + DDLogInfo(@"Got %@ upload: %@", payload[@"type"], payload[@"data"]); + [self.activeChats.currentChatView showUploadHUD]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + $call(payload[@"data"], $ID(account), $BOOL(encrypted), $ID(completion, (^(NSString* url, NSString* mimeType, NSNumber* size, NSError* error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if(error != nil) + { + DDLogError(@"Failed to upload outbox file: %@", error); + NSMutableDictionary* payloadCopy = [NSMutableDictionary dictionaryWithDictionary:payload]; + cleanup(payloadCopy); + + UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Failed to share file", @"") message:[NSString stringWithFormat:NSLocalizedString(@"Error: %@", @""), error] preferredStyle:UIAlertControllerStyleAlert]; + [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + }]]; + [self.activeChats presentViewController:messageAlert animated:YES completion:nil]; + } + else + [[MLXMPPManager sharedInstance] sendMessageAndAddToHistory:url havingType:kMessageTypeFiletransfer toContact:contact isEncrypted:encrypted uploadInfo:@{@"mimeType": mimeType, @"size": size} withCompletionHandler:^(BOOL successSendObject, NSString* messageIdSentObject) { + DDLogInfo(@"SHARESHEET_SEND_DATA success=%@, account=%@, messageIdSentObject=%@", bool2str(successSendObject), account.accountID, messageIdSentObject); + cleanup(payload); + }]; + }); + }))); + }); + } + else + unreachable(@"Outbox payload type unknown", payload); + }; + + DDLogVerbose(@"Trying to open chat of outbox receiver: %@", contact); + [[DataLayer sharedInstance] addActiveBuddies:contact.contactJid forAccount:contact.accountID]; + //don't use [self openChatOfContact:withCompletion:] because it's asynchronous and can only handle one contact at a time (e.g. until the asynchronous execution finished) + //we can invoke the activeChats interface directly instead, because we already did the necessary preparations ourselves + [(ActiveChatsViewController*)self.activeChats presentChatWithContact:contact andCompletion:sendItem]; + + //only send one item at a time (this method will be invoked again when sending completed) + break; + } + }]; +} + +@end diff --git a/Monal/Classes/NotificationDebugging.swift b/Monal/Classes/NotificationDebugging.swift new file mode 100644 index 0000000..ebe6bdc --- /dev/null +++ b/Monal/Classes/NotificationDebugging.swift @@ -0,0 +1,132 @@ +// +// NotificationDebugging.swift +// Monal +// +// Created by Jan on 02.05.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +import OrderedCollections + +class NotificationDebuggingDefaultsDB: ObservableObject { + @defaultsDB("lastAppexStart") + var lastAppexStart: Date? +} + +struct NotificationDebugging: View { + private let applePushEnabled: Bool + private let applePushToken: String + private let xmppAccountInfo: [xmpp] + + private let availablePushServers: Dictionary + + @State private var pushPermissionEnabled = false // state because we get this value through an async call + @State private var showPushToken = false + + @State private var selectedPushServer: String + + @ObservedObject var notificationDebuggingDefaultsDB = NotificationDebuggingDefaultsDB() + + var body: some View { + Form { + Group { + Section(header: Text("Status").font(.title3)) { + VStack(alignment: .leading, spacing:10) { + buildNotificationStateLabel(Text("Apple Push Service"), isWorking: self.applePushEnabled); + Divider() + Text("Apple push service should always be on. If it is off, your device can not talk to Apple's server.").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote) + if !self.applePushEnabled, let apnsError = MLXMPPManager.sharedInstance().apnsError { + Text("Error: \(String(describing:apnsError))").foregroundColor(.red).font(.footnote) + } + if let lastAppexStart = notificationDebuggingDefaultsDB.lastAppexStart { + Text("Last incoming push: \(String(describing:lastAppexStart))").foregroundColor(.gray).font(.footnote) + } else { + Text("Last incoming push: unknown").foregroundColor(.gray).font(.footnote) + } + }.onTapGesture(count: 2, perform: { + showPushToken = true + }).alert(isPresented: $showPushToken) { + (self.applePushEnabled == true) ? + Alert( + title: Text("Apple Push Token"), + message: Text(self.applePushToken), + primaryButton: .default(Text("Copy to clipboard"), + action: { + UIPasteboard.general.string = self.applePushToken; + }), + secondaryButton: .destructive(Text("Close"))) + : + Alert(title: Text("Apple Push Token is not available!")) + } + } + Section { + VStack(alignment: .leading) { + buildNotificationStateLabel(Text("Can Show Notifications"), isWorking: self.pushPermissionEnabled); + Divider() + Text("If Monal can't show notifications, you will not see alerts when a message arrives. This happens if you tapped 'Decline' when Monal first asked permission. Fix it by going to iOS Settings -> Monal -> Notifications and select 'Allow Notifications'.").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote) + } + } + if(self.xmppAccountInfo.count > 0) { + Section { + VStack(alignment: .leading) { + ForEach(self.xmppAccountInfo, id: \.self) { account in + buildNotificationStateLabel(Text(account.connectionProperties.identity.jid), isWorking: account.connectionProperties.pushEnabled) + Divider() + } + Text("If this is off your device could not activate push on your xmpp server, make sure to have configured it to support XEP-0357.").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote) + } + } + } else { + Section { + Text("No accounts set up currently").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote) + }.opacity(0.5) + } + } + Section(header: Text("Pushserver Region").font(.title3)) { + Picker(selection: $selectedPushServer, label: Text("Push Server")) { + ForEach(self.availablePushServers.sorted(by: >), id: \.key) { pushServerFqdn, pushServerName in + Text(pushServerName).tag(pushServerFqdn) + } + }.pickerStyle(.menu)//.menuStyle(.borderlessButton) + .onChange(of: selectedPushServer) { pushServerFqdn in + DDLogDebug("Selected \(pushServerFqdn) as push server") + HelperTools.defaultsDB().setValue(pushServerFqdn, forKey: "selectedPushServer") + // enable push again to switch to the selected server + for account in self.xmppAccountInfo { + account.enablePush() + } + } + } +#if DEBUG + Section(header: Text("Debugging").font(.title3)) { + Button("Reregister push token") { + UIApplication.shared.unregisterForRemoteNotifications() + UIApplication.shared.registerForRemoteNotifications() + } + } +#endif + } + .navigationBarTitle(Text("Notifications")) + .onAppear(perform: { + UNUserNotificationCenter.current().getNotificationSettings { (settings) -> Void in + self.pushPermissionEnabled = (settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional); + } + }); + } + + init() { + self.applePushEnabled = MLXMPPManager.sharedInstance().hasAPNSToken; + self.applePushToken = MLXMPPManager.sharedInstance().pushToken; + self.xmppAccountInfo = MLXMPPManager.sharedInstance().connectedXMPP as! [xmpp] + + // push server selector + self.availablePushServers = HelperTools.getAvailablePushServers() + self.selectedPushServer = HelperTools.defaultsDB().object(forKey: "selectedPushServer") as! String + } +} + +struct PushSettings_Previews: PreviewProvider { + static var previews: some View { + NotificationSettings() + } +} diff --git a/Monal/Classes/OmemoKeysView.swift b/Monal/Classes/OmemoKeysView.swift new file mode 100644 index 0000000..7b457a8 --- /dev/null +++ b/Monal/Classes/OmemoKeysView.swift @@ -0,0 +1,489 @@ +// +// OmemoKeys.swift +// Monal +// +// Created by Jan on 04.05.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +import OrderedCollections + +struct OmemoKeysEntryView: View { + private let contactJid: String + + @State private var trustLevel: NSNumber + @State private var showEntryInfo = false + @State private var showClipboardCopy = false + + private let deviceId: NSNumber + private let fingerprint: Data + private let address: SignalAddress + private let account: xmpp + private let isOwnDevice: Bool + private let isBrokenSession: Bool + + init(account: xmpp, contactJid: String, deviceId: NSNumber, isOwnDevice: Bool) { + self.contactJid = contactJid + self.deviceId = deviceId + self.isOwnDevice = isOwnDevice + self.address = SignalAddress.init(name: contactJid, deviceId: Int32(deviceId.int32Value)) + self.fingerprint = account.omemo.getIdentityFor(self.address) + self.trustLevel = account.omemo.getTrustLevel(self.address, identityKey: self.fingerprint) + self.account = account + self.isBrokenSession = account.omemo.isSessionBroken(forJid:contactJid, andDeviceId:deviceId) + } + + func setTrustLevel(_ enableTrust: Bool) { + self.account.omemo.updateTrust(enableTrust, for: self.address) + self.trustLevel = self.account.omemo.getTrustLevel(self.address, identityKey: self.fingerprint) + } + + func getEntryInfoAlert() -> Alert { + if(self.isOwnDevice) { + return Alert( + title: Text("Own device key"), + message: Text("This key belongs to this device and cannot be removed or disabled!"), + dismissButton: nil); + } + switch(self.trustLevel.int32Value) { + case MLOmemoTrusted: + return Alert( + title: Text("Trusted and verified key"), + message: Text("This key is trusted and verified by manually comparing fingerprints. To stop trusting this key, use the toggle element."), + dismissButton: nil) + case MLOmemoToFU: + return Alert( + title: Text("Trusted but unverified key"), + message: Text("Monal currently trusts this key, but fingerprints were not compared yet. To increase security, please confirm with the contact that the displayed fingerprints do match before trusting this key!"), + primaryButton: .destructive(Text("Trust Key"), action: { + setTrustLevel(true) + }), + secondaryButton: .default(Text("Okay"))) + case MLOmemoNotTrusted: + return Alert( + title: Text("Untrusted key"), + message: Text("Monal does not trust this key. Either it was manually disabled or not manually verified while other keys of that contact are verified. You can trust this key by using the toggle element. Please ensure with the contact that fingerprints are matching before trusting this key."), + dismissButton: nil) + case MLOmemoTrustedButRemoved: + return Alert( + title: Text("Trusted but removed key"), + message: Text("This key is trusted, but the contact does not use it anymore. Consider to disable trust for this key."), + primaryButton: .default(Text("Dont' trust Key"), action: { + setTrustLevel(false) + }), + secondaryButton: .cancel(Text("Okay"))) + case MLOmemoTrustedButNoMsgSeenInTime: + return Alert( + title: Text("Trusted but unused key"), + message: Text("This key is trusted, but the contact has not used it for a long time. Consider to disable trust for this key"), + primaryButton: .default(Text("Don't trust Key"), action: { + setTrustLevel(false) + }), + secondaryButton: .cancel(Text("Okay"))) + default: + return Alert( + title: Text("Invalid State"), + message: Text("The key is in a state that is currently not correctly handled. Please contact the developers if you see this prompt."), + dismissButton: nil) + } + } + + // @ViewBuilder + func getTrustLevelIcon() -> some View { + var iconColor = Color.yellow + var iconName = "key.fill" + switch(self.trustLevel.int32Value) { + case MLOmemoTrusted: + iconColor = Color.green + break + case MLOmemoToFU: + break + case MLOmemoNotTrusted: + iconColor = Color.red + break + case MLOmemoTrustedButRemoved: + iconName = "trash.fill" + case MLOmemoTrustedButNoMsgSeenInTime: + iconName = "clock.fill" + default: + break + } + return Image(systemName: iconName) + .frame(width: 30, height: 30, alignment: .center) + .foregroundColor(Color.primary) + .background(iconColor) + .cornerRadius(30) + } + + func getDeviceIconForOwnDevice() -> some View { + var deviceImage: String = "iphone.homebutton.circle" + if UIDevice.current.userInterfaceIdiom == .pad { +#if targetEnvironment(macCatalyst) + deviceImage = "laptopcomputer" +#else + deviceImage = "ipad" +#endif + } + return Image(systemName: deviceImage) + .resizable() + .frame(width: 30, height: 30, alignment: .center) + .foregroundColor(Color.primary) + } + + var body: some View { + let trustLevelBinding = Binding.init(get: { + return (self.trustLevel.int32Value != MLOmemoNotTrusted) + }, set: { keyEnabled in + setTrustLevel(keyEnabled) + }) + + let fingerprintString: String = self.fingerprint.isEmpty ? "" : HelperTools.signalHexKeyWithSpaces(with: fingerprint) + let clipboardValue = "OMEMO fingerprint of \(self.contactJid), device \(self.deviceId): \(fingerprintString)" + GroupBox { + HStack(alignment:.bottom) { + VStack(alignment:.leading) { + HStack(alignment:.center) { + Text("Device ID: ").font(.headline) + Text(deviceId.stringValue) + } + Spacer() + HStack(alignment:.center) { + Text(fingerprintString) + .font(Font.init( + UIFont.monospacedSystemFont(ofSize: 11.0, weight: .regular) + )) + if(self.isBrokenSession) { + Text("Encrypted session to this device broken beyond repair.").foregroundColor(.red) + } + } + } + .onTapGesture(count: 2) { + UIPasteboard.general.setValue(clipboardValue, forPasteboardType:UTType.utf8PlainText.identifier) + showClipboardCopy = true + } + Spacer() + // the trust level of our own device should not be displayed + if(!isOwnDevice) { + VStack(alignment:.center) { + Button { + showEntryInfo = true + } label: { + getTrustLevelIcon() + } + Toggle("", isOn: trustLevelBinding).font(.footnote) + .labelsHidden() //make sure we do not need more space than the actual toggle needs + } + } else { + Button { + showEntryInfo = true + } label: { + getDeviceIconForOwnDevice() + } + } + } + .alert(isPresented: $showEntryInfo) { + getEntryInfoAlert() + } + .alert(isPresented: $showClipboardCopy) { + Alert( + title: Text("Copied to clipboard"), + message: Text(clipboardValue), + dismissButton: nil + ); + } + } + } +} + +struct OmemoKeysForContactView: View { + @State private var showDeleteKeyAlert = false + @State private var selectedDeviceForDeletion : NSNumber + @ObservedObject private var devices: OmemoKeysForContact + + private var deviceId: NSNumber { + return account.omemo.getDeviceId() + } + + private var deviceIds: OrderedSet { + return OrderedSet(devices.devices.sorted { $0.intValue < $1.intValue }) + } + + private let contactJid: String + private let account: xmpp + private let ownKeys: Bool + + init(contact: ObservableKVOWrapper, devices: OmemoKeysForContact) { + let account = (contact.account as xmpp?)! + self.ownKeys = (account.connectionProperties.identity.jid == contact.obj.contactJid) + self.contactJid = contact.obj.contactJid + self.account = account + self.devices = devices + self.selectedDeviceForDeletion = -1 + } + + func deleteButton(deviceId: NSNumber) -> some View { + Button(action: { + selectedDeviceForDeletion = deviceId // SwiftUI does not like to have deviceID nested in multiple functions, so safe this in the struct... + showDeleteKeyAlert = true + }, label: { + Image(systemName: "xmark.circle.fill").foregroundColor(.red) + }) + .buttonStyle(.borderless) + .offset(x: -7, y: -7) + .alert(isPresented: $showDeleteKeyAlert) { + Alert( + title: Text("Do you really want to delete this key?"), + message: Text("DeviceID: " + self.selectedDeviceForDeletion.stringValue), + primaryButton: .destructive(Text("Delete Key")) { + if(deviceId == -1) { + return // should be unreachable + } + account.omemo.deleteDevice(forSource: self.contactJid, andRid: self.selectedDeviceForDeletion) + }, + secondaryButton: .cancel(Text("Abort")) + ) + } + } + + var body: some View { + ForEach(self.deviceIds, id: \.self) { deviceId in + HStack { + ZStack(alignment: .topLeading) { + OmemoKeysEntryView(account: self.account, contactJid: self.contactJid, deviceId: deviceId, isOwnDevice: (ownKeys && deviceId == self.deviceId)) + if(ownKeys == true) { + if(deviceId != self.deviceId) { + deleteButton(deviceId: deviceId) + } + } + } + } + } + } +} + +struct OmemoKeysForChatView: View { + private var viewContact: ObservableKVOWrapper? // store initial contact with which the view was initialized for refreshs... + private var account: xmpp? + + // Needed for the alert message that is displayed when the scanned contact is not in the group + @State private var scannedJid : String = "" + @State private var scannedFingerprints : Dictionary = [:] + + @State var selectedContact : ObservableKVOWrapper? // for reason why see start of body + @State private var navigateToQRCodeView = false + @State private var navigateToQRCodeScanner = false + + @State private var showScannedContactMissmatchAlert = false + + @ObservedObject private var omemoKeys: OmemoKeysForChat + + private var contacts: [(ObservableKVOWrapper, OmemoKeysForContact)] { + return omemoKeys.contacts.sorted { (entry1, entry2) -> Bool in + let entry1Jid: String = entry1.0.contactJid + let entry2Jid: String = entry2.0.contactJid + return entry1Jid < entry2Jid + } + } + + init(omemoKeys: OmemoKeysForChat) { + self.account = omemoKeys.viewContact?.account + self.selectedContact = nil + self.viewContact = omemoKeys.viewContact + self.omemoKeys = omemoKeys + } + + private func isOwnKeys() -> Bool { + if let contact = self.viewContact, let account = self.account { + let isMuc = contact.isMuc && contact.mucType == kMucTypeGroup + let isOwnJid = account.connectionProperties.identity.jid == contact.contactJid + return !isMuc && isOwnJid + } + return false + } + + func resetTrustFromQR(scannedJid : String, scannedFingerprints : Dictionary) { + //don't untrust other devices not included in here, because conversations only exports its own fingerprint +// // untrust all devices from jid +// self.account!.omemo.untrustAllDevices(from: scannedJid) + // trust all devices that were part of the qr code + let knownDevices = Array(self.account!.omemo.knownDevices(forAddressName: scannedJid)) + for (qrDeviceId, fingerprint) in scannedFingerprints { + let address = SignalAddress(name: scannedJid, deviceId: Int32(qrDeviceId)) + let identityFromHex = HelperTools.signalIdentity(withHexKey: fingerprint) + // insert fingerprint of unkown devices to signalstore + if(!knownDevices.contains(NSNumber(integerLiteral: qrDeviceId))) { + self.account!.omemo.addIdentityManually(address, identityKey: identityFromHex) + assert(self.account!.omemo.getIdentityFor(address) == identityFromHex, "The stored and created fingerprint should match") + } + // trust device/fingerprint if fingerprints match + let identity = self.account!.omemo.getIdentityFor(address) + let knownIdentity = HelperTools.signalHexKey(with: identity) + if(knownIdentity.uppercased() == fingerprint.uppercased()) { + self.account!.omemo.updateTrust(true, for: address) + } + } + } + + var body: some View { + // workaround for the fact that NavigationLink inside a form forces a formatting we don't want + if(self.selectedContact != nil) { // selectedContact is set to a value either when the user presses a QR code button or if there is only a single contact to choose from (-> user views a single account) + NavigationLink(destination:LazyClosureView(OmemoQrCodeView(contact: self.selectedContact!)), isActive: $navigateToQRCodeView){}.hidden().disabled(true) // navigation happens as soon as our button sets navigateToQRCodeView to true... +// NavigationLink(destination: LazyClosureView(MLQRCodeScanner( +// handleContact: { jid, fingerprints in +// // we scanned a contact but it was not in the contact list, show the alert... +// self.scannedJid = jid +// self.scannedFingerprints = fingerprints +// showScannedContactMissmatchAlert = true +// }, handleClose: {} +// )), isActive: $navigateToQRCodeScanner){}.hidden().disabled(true) + } + List { + let helpDescription = isOwnKeys() ? + Text("These are your encryption keys. Each device is a different place you have logged in. You should trust a key when you have verified it. Double tap onto a fingerprint to copy to clipboard.") : + Text("You should trust a key when you have verified it. Verify by comparing the key below to the one on your contact's screen. Double tap onto a fingerprint to copy to clipboard.") + + Section(header:helpDescription) { + if (omemoKeys.contacts.count == 1) { + ForEach(self.contacts, id: \.0) { contact, devices in + OmemoKeysForContactView(contact: contact, devices: devices) + } + } else { + ForEach(self.contacts, id: \.0) { contact, devices in + DisclosureGroup(content: { + OmemoKeysForContactView(contact: contact, devices: devices) + }, label: { + HStack { + Text("Keys of \(contact.obj.contactJid)") + Spacer() + Button(action: { + self.selectedContact = contact + self.navigateToQRCodeView = true + }, label: { + Image(systemName: "qrcode.viewfinder") + }).buttonStyle(.borderless) + } + }) + } + } + } + } + .listStyle(.plain) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack{ + /*if(self.account != nil) { + Button(action: { + self.navigateToQRCodeScanner = true + }, label: { + Image(systemName: "camera.fill") + }) + }*/ + if(omemoKeys.contacts.count == 1 && self.account != nil) { + Button(action: { + self.navigateToQRCodeView = true + }, label: { + Image(systemName: "qrcode.viewfinder") + }) + } + } + } + } + .navigationBarTitle(isOwnKeys() ? Text("My Encryption Keys") : Text("Encryption Keys"), displayMode: .inline) + .onAppear(perform: { + self.selectedContact = self.omemoKeys.contacts.keys.first // needs to be done here as first is nil in init + }) + .alert(isPresented: $showScannedContactMissmatchAlert) { + Alert( + title: Text("QR code: Fingerprints found"), + message: Text("Do you want to trust the scanned fingerprints of contact \(self.scannedJid) when using your account \(self.account!.connectionProperties.identity.jid)?"), + primaryButton: .cancel(Text("No")), + secondaryButton: .default(Text("Yes"), action: { + resetTrustFromQR(scannedJid: self.scannedJid, scannedFingerprints: self.scannedFingerprints) + self.scannedJid = "" + self.scannedFingerprints = [:] + })) + } + } +} + +struct OmemoKeysView: View { + @ObservedObject private var omemoKeys: OmemoKeysForChat + + init(omemoKeys: OmemoKeysForChat) { + self.omemoKeys = omemoKeys + } + + private var viewContact: ObservableKVOWrapper? { + return omemoKeys.viewContact + } + + private var account: xmpp? { + return viewContact?.account + } + + private var contacts: Set> { + return Set(omemoKeys.contacts.keys) + } + + var body: some View { + if self.account != nil && !self.contacts.isEmpty { + OmemoKeysForChatView(omemoKeys: omemoKeys) + } else if self.account == nil { + ContentUnavailableShimView("Account Disabled", systemImage: "iphone.homebutton.slash", description: Text("Cannot display keys as the account is disabled.")) + } else if self.contacts.isEmpty { + ContentUnavailableShimView("No Contacts", systemImage: "person.2.slash", description: Text("Cannot display keys as there are no contacts to display keys for.")) + } + } +} + +class OmemoKeysForContact: ObservableObject { + @Published var devices: Set + + init(devices: Set) { + self.devices = devices + } +} + +class OmemoKeysForChat: ObservableObject { + @Published var contacts: Dictionary, OmemoKeysForContact> + var viewContact: ObservableKVOWrapper? + private var subscriptions: Set = Set() + + init(viewContact: ObservableKVOWrapper?) { + self.viewContact = viewContact + self.contacts = OmemoKeysForChat.knownDevices(viewContact: self.viewContact) + subscriptions = [ + NotificationCenter.default.publisher(for: NSNotification.Name("kMonalOmemoStateUpdated")) + .receive(on: DispatchQueue.main) + .sink() { _ in self.updateContactDevices() }, + NotificationCenter.default.publisher(for: NSNotification.Name("kMonalMucParticipantsAndMembersUpdated")) + .receive(on: DispatchQueue.main) + .sink() { _ in self.updateContactDevices() }, + ] + } + + private func updateContactDevices() -> Void { + withAnimation() { + self.contacts = OmemoKeysForChat.knownDevices(viewContact: self.viewContact) + } + } + + private static func knownDevices(viewContact: ObservableKVOWrapper?) -> Dictionary, OmemoKeysForContact> { + let contacts: OrderedSet> = getContactList(viewContact: viewContact) + let devices = contacts.map { ($0, devicesForContact(contact: $0)) } + return Dictionary(uniqueKeysWithValues: devices) + } + + private static func devicesForContact(contact: ObservableKVOWrapper) -> OmemoKeysForContact { + let account: xmpp = (contact.account as xmpp?)! + let devicesForContact: Set = account.omemo.knownDevices(forAddressName: contact.contactJid) + return OmemoKeysForContact(devices: devicesForContact) + } +} + +struct OmemoKeys_Previews: PreviewProvider { + static var previews: some View { + // TODO some dummy views, requires a dummy xmpp obj + OmemoKeysView(omemoKeys: OmemoKeysForChat(viewContact: nil)); + } +} diff --git a/Monal/Classes/OmemoQrCodeView.swift b/Monal/Classes/OmemoQrCodeView.swift new file mode 100644 index 0000000..c410286 --- /dev/null +++ b/Monal/Classes/OmemoQrCodeView.swift @@ -0,0 +1,68 @@ +// +// MLOmemoQrCodeView.swift +// Monal +// +// Created by Friedrich Altheide on 20.02.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +import CoreImage.CIFilterBuiltins + +func createQrCode(value: String) -> UIImage +{ + let qrCodeFilter = CIFilter.qrCodeGenerator() + // set qrcode value + qrCodeFilter.message = Data(value.utf8) + + if let qrCodeImage = qrCodeFilter.outputImage { + if let t = CIContext().createCGImage(qrCodeImage, from: qrCodeImage.extent) { + return UIImage(cgImage: t) + } + } + + return UIImage() +} + +struct OmemoQrCodeView: View { + let jid: String + @State private var qrCodeImage: UIImage + + init(contact: ObservableKVOWrapper) + { + self.jid = contact.obj.contactJid + if let account = contact.obj.account { + let devices = Array(account.omemo.knownDevices(forAddressName: self.jid)) + var keyList = "" + var prefix = "?" + for device in devices { + let address = SignalAddress.init(name: self.jid, deviceId: device.int32Value) + let identity = account.omemo.getIdentityFor(address) + + if(account.omemo.isTrustedIdentity(address, identityKey: identity)) { + let hexIdentity = String(HelperTools.signalHexKey(with: identity)) + let keyString = String(format: "%@omemo-sid-%@=%@", prefix, device, hexIdentity) + keyList += keyString + prefix = ";" + } + } + self.qrCodeImage = createQrCode(value: String(format:"xmpp:%@%@", jid, keyList)) + } else { + self.qrCodeImage = UIImage() + } + } + + var body: some View { + Image(uiImage: qrCodeImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .aspectRatio(1, contentMode: .fit) + .navigationBarTitle(Text("Keys of \(self.jid)"), displayMode: .inline) + } +} + +struct OmemoQrCodeView_Previews: PreviewProvider { + static var previews: some View { + OmemoQrCodeView(contact: ObservableKVOWrapper(MLContact.makeDummyContact(0))) + } +} diff --git a/Monal/Classes/OmemoState.h b/Monal/Classes/OmemoState.h new file mode 100644 index 0000000..e44d836 --- /dev/null +++ b/Monal/Classes/OmemoState.h @@ -0,0 +1,33 @@ +// +// OmemoState.h +// monalxmpp +// +// Created by Thilo Molitor on 05.11.22. +// Copyright © 2022 monal-im.org. All rights reserved. +// + +#ifndef OmemoState_h +#define OmemoState_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface OmemoState : NSObject +-(void) updateWith:(OmemoState*) state; +// *** data *** +@property (nonatomic, strong) NSMutableDictionary*>* openBundleFetches; +@property (nonatomic, strong) NSMutableSet* openDevicelistFetches; +@property (nonatomic, strong) NSMutableSet* openDevicelistSubscriptions; +@property (nonatomic, strong) NSMutableDictionary*>* queuedKeyTransportElements; +// jid -> @[deviceID1, deviceID2] +@property (nonatomic, strong) NSMutableDictionary*>* queuedSessionRepairs; + +// *** flags *** +@property (atomic, assign) BOOL hasSeenDeviceList; +@property (atomic, assign) BOOL catchupDone; +@end + +NS_ASSUME_NONNULL_END + +#endif /* OmemoState_h */ diff --git a/Monal/Classes/OmemoState.m b/Monal/Classes/OmemoState.m new file mode 100644 index 0000000..e2fccd2 --- /dev/null +++ b/Monal/Classes/OmemoState.m @@ -0,0 +1,77 @@ +// +// OmemoState.m +// monalxmpp +// +// Created by admin on 05.11.22. +// Copyright © 2022 monal-im.org. All rights reserved. +// + +#import +#import "MLConstants.h" +#import "OmemoState.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation OmemoState + +-(instancetype) init +{ + self = [super self]; + self.queuedKeyTransportElements = [NSMutableDictionary new]; + self.openBundleFetches = [NSMutableDictionary new]; + self.openDevicelistFetches = [NSMutableSet new]; + self.openDevicelistSubscriptions = [NSMutableSet new]; + self.queuedSessionRepairs = [NSMutableDictionary new]; + self.catchupDone = NO; + self.hasSeenDeviceList = NO; + return self; +} + +-(void) updateWith:(OmemoState*) state +{ + self.queuedKeyTransportElements = state.queuedKeyTransportElements; + self.openBundleFetches = state.openBundleFetches; + self.openDevicelistFetches = state.openDevicelistFetches; + self.openDevicelistSubscriptions = state.openDevicelistSubscriptions; + self.queuedSessionRepairs = state.queuedSessionRepairs; + self.catchupDone = state.catchupDone; + self.hasSeenDeviceList = state.hasSeenDeviceList; +} + ++(BOOL) supportsSecureCoding +{ + return YES; +} + +-(void) encodeWithCoder:(NSCoder*) coder +{ + [coder encodeObject:self.openBundleFetches forKey:@"openBundleFetches"]; + [coder encodeObject:self.openDevicelistFetches forKey:@"openDevicelistFetches"]; + [coder encodeObject:self.openDevicelistSubscriptions forKey:@"openDevicelistSubscriptions"]; + [coder encodeObject:self.queuedKeyTransportElements forKey:@"queuedKeyTransportElements"]; + [coder encodeObject:self.queuedSessionRepairs forKey:@"queuedSessionRepairs"]; + [coder encodeBool:self.hasSeenDeviceList forKey:@"hasSeenDeviceList"]; + [coder encodeBool:self.catchupDone forKey:@"catchupDone"]; +} + +-(instancetype _Nullable) initWithCoder:(NSCoder*) coder +{ + self = [self init]; + self.openBundleFetches = [coder decodeObjectForKey:@"openBundleFetches"]; + self.openDevicelistFetches = [coder decodeObjectForKey:@"openDevicelistFetches"]; + self.openDevicelistSubscriptions = [coder decodeObjectForKey:@"openDevicelistSubscriptions"]; + self.queuedKeyTransportElements = [coder decodeObjectForKey:@"queuedKeyTransportElements"]; + self.queuedSessionRepairs = [coder decodeObjectForKey:@"queuedSessionRepairs"]; + self.hasSeenDeviceList = [coder decodeBoolForKey:@"hasSeenDeviceList"]; + self.catchupDone = [coder decodeBoolForKey:@"catchupDone"]; + return self; +} + +-(NSString*) description +{ + return [NSString stringWithFormat:@"OmemoState(\n\topenBundleFetches=%@\n\topenDevicelistFetches=%@\n\topenDevicelistSubscriptions=%@\n\tqueuedKeyTransportElements=%@\n\tqueuedSessionRepairs=%@\n\thasSeenDeviceList=%@\n\tcatchupDone=%@\n)", self.openBundleFetches, self.openDevicelistFetches, self.openDevicelistSubscriptions, self.queuedKeyTransportElements, self.queuedSessionRepairs, bool2str(self.hasSeenDeviceList), bool2str(self.catchupDone)]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/PasswordMigration.swift b/Monal/Classes/PasswordMigration.swift new file mode 100644 index 0000000..9b9ea2b --- /dev/null +++ b/Monal/Classes/PasswordMigration.swift @@ -0,0 +1,150 @@ +// +// PasswordMigration.swift +// Monal +// +// Created by Thilo Molitor on 01.08.22. +// Copyright © 2022 monal-im.org. All rights reserved. +// + +struct PasswordMigration: View { + let delegate: SheetDismisserProtocol + @State var needingMigration: [Int:[String:NSObject]] +#if IS_ALPHA + let appLogoId = "AlphaAppLogo" +#elseif IS_QUICKSY + let appLogoId = "QuicksyAppLogo" +#else + let appLogoId = "AppLogo" +#endif + + init(delegate:SheetDismisserProtocol, needingMigration:[[String:NSObject]]) { + self.delegate = delegate + var tmpState = [Int:[String:NSObject]]() + for entry in needingMigration { + let id = (entry["account_id"] as! NSNumber).intValue + tmpState[id] = entry + } + self.needingMigration = tmpState + DDLogInfo("Migration needed: \(String(describing:self.needingMigration))") + } + + 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("Your accounts got deactivated, because you restored an iCloud backup of Monal. Please reenter your passwords to activate them again.") + .padding() + .padding(.leading, -16.0) + } + .frame(maxWidth: .infinity) + .background(Color(UIColor.systemBackground)) + + List { + ForEach(Array(self.needingMigration.keys.enumerated()), id:\.element) { _, id in + let jid = "\(self.needingMigration[id]?["username"] ?? "" as NSString)@\(self.needingMigration[id]?["domain"] ?? "" as NSString)" + VStack { + Toggle(jid, isOn:Binding( + //our toggle is on, if we have a password AND the account is in enabled state + //(e.g. the user can enter a password, but keep the account disabled, if he wants) + get: { (self.needingMigration[id]?["password"] as? String ?? "") != "" && (self.needingMigration[id]?["enabled"] as! NSNumber).boolValue }, + set: { + if((self.needingMigration[id]?["password"] as? String ?? "") != "") { + self.needingMigration[id]?["enabled"] = NSNumber(value:$0) + } + } + )) + + SecureField(NSLocalizedString("Password", comment: "placeholder when migrating account"), text:Binding( + get: { self.needingMigration[id]?["password"] as? String ?? "" }, + set: { + self.needingMigration[id]?["password"] = $0 as NSString + if($0.count > 0) { + //first change? --> activate account and use "needs_password_migration" to record + //the fact that we just activated this account automatically + //(making the password field empty will reset this) + if((self.needingMigration[id]?["needs_password_migration"] as! NSNumber).boolValue) { + self.needingMigration[id]?["enabled"] = NSNumber(value:true) + self.needingMigration[id]?["needs_password_migration"] = NSNumber(value:false) + } + //reset our "account automatically activated" flag and deactivate our account + } else { + self.needingMigration[id]?["enabled"] = NSNumber(value:false) + self.needingMigration[id]?["needs_password_migration"] = NSNumber(value:true) + } + } + )) + .addClearButton(isEditing: true, text:Binding( + get: { self.needingMigration[id]?["password"] as? String ?? "" }, + set: { self.needingMigration[id]?["password"] = $0 as NSString } + )) + } + } + } + .listStyle(.insetGrouped) + } + //} + .textFieldStyle(.roundedBorder) + .navigationBarTitle(Text("Migration Assistant")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack{ + Button(action: { + DDLogInfo("Saving migrated accounts: \(String(describing:self.needingMigration))") + for id in self.needingMigration.keys { + var dic = self.needingMigration[id]! + //don't show this migration dialog again, even if the user did not activate this account + dic["needs_password_migration"] = NSNumber(value:false) + if let password = dic["password"] as? String, password.count > 0 { + DDLogDebug("Updating account in DB: enabled=\(String(describing:dic["enabled"])), needs_password_migration=\(String(describing:dic["needs_password_migration"])), password.count=\(password.count)") + DataLayer.sharedInstance().updateAccoun(with:dic) + MLXMPPManager.sharedInstance().updatePassword(password, forAccount:dic["account_id"] as! NSNumber) + if((self.needingMigration[id]?["enabled"] as! NSNumber).boolValue) { + DDLogDebug("Connecting now enabled account...") + MLXMPPManager.sharedInstance().connectAccount(dic["account_id"] as! NSNumber) + } + } else { + DDLogDebug("Updating account in DB: enabled=\(String(describing:dic["enabled"])), needs_password_migration=\(String(describing:dic["needs_password_migration"])), password.count=0") + DataLayer.sharedInstance().updateAccoun(with:dic) + } + } + NotificationCenter.default.post(name:Notification.Name("kMonalRefresh"), object:nil); + self.delegate.dismiss() + }, label: { + Text("Done") + }) + } + } + } + } +} + +func previewMock() -> [[String:NSObject]] { + return [ + [ + "account_id": NSNumber(value:1), + "enabled": NSNumber(value:false), + "needs_password_migration": NSNumber(value:true), + "username": "user1" as NSString, + "domain": "example.org" as NSString + ], + [ + "account_id": NSNumber(value:2), + "enabled": NSNumber(value:false), + "needs_password_migration": NSNumber(value:true), + "username": "user2" as NSString, + "domain": "example.com" as NSString + ] + ] +} + +struct PasswordMigration_Previews: PreviewProvider { + static var delegate = SheetDismisserProtocol() + static var previews: some View { + PasswordMigration(delegate:delegate, needingMigration:previewMock()) + } +} diff --git a/Monal/Classes/QRCodeScannerLoginView.swift b/Monal/Classes/QRCodeScannerLoginView.swift new file mode 100644 index 0000000..913a4b2 --- /dev/null +++ b/Monal/Classes/QRCodeScannerLoginView.swift @@ -0,0 +1,43 @@ +// +// QRCodeScannerView.swift +// Monal +// +// Created by CC on 07.05.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +struct QRCodeScannerLoginView: UIViewControllerRepresentable { + @Binding private var account : String + @Binding private var password : String + + class Coordinator: NSObject, MLLQRCodeScannerAccountLoginDelegate { + var parent: QRCodeScannerLoginView + + init(_ parent: QRCodeScannerLoginView) { + self.parent = parent + } + + func MLQRCodeAccountLoginScanned(jid: String, password: String) { + parent.account = jid + parent.password = password + } + } + + init(_ account: Binding, _ password: Binding) { + self._account = account + self._password = password + } + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> MLQRCodeScanner { + let qrCodeScannerViewController = MLQRCodeScanner() + qrCodeScannerViewController.loginDelegate = context.coordinator + return qrCodeScannerViewController + } + + func updateUIViewController(_ uiViewController: MLQRCodeScanner, context: UIViewControllerRepresentableContext) { + } + + func makeCoordinator() -> QRCodeScannerLoginView.Coordinator { + Coordinator(self) + } +} diff --git a/Monal/Classes/Quicksy_Country.h b/Monal/Classes/Quicksy_Country.h new file mode 100644 index 0000000..f4f1294 --- /dev/null +++ b/Monal/Classes/Quicksy_Country.h @@ -0,0 +1,24 @@ +// +// Quicksy_Country.h +// Monal +// +// Created by Thilo Molitor on 28.08.24. +// Copyright © 2024 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface Quicksy_Country : NSObject + @property (readonly) NSString* id; //for Identifiable protocol + @property (readonly) NSString* _Nullable name; //has to be optional because we don't want to have NSLocalizedString() if we know the alpha-2 code + @property (readonly) NSString* _Nullable alpha2; //has to be optional because the alpha-2 mapping can fail + @property (readonly) NSString* code; + @property (readonly) NSString* pattern; + + -(instancetype) initWithName:(NSString* _Nullable) name alpha2:(NSString* _Nullable) alpha2 code:(NSString*) code pattern:(NSString*) pattern; + +(BOOL) supportsSecureCoding; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/Quicksy_Country.m b/Monal/Classes/Quicksy_Country.m new file mode 100644 index 0000000..1ffb2e2 --- /dev/null +++ b/Monal/Classes/Quicksy_Country.m @@ -0,0 +1,64 @@ +// +// Quicksy_Country.m +// Monal +// +// Created by Thilo Molitor on 28.08.24. +// Copyright © 2024 Monal.im. All rights reserved. +// + +#import "Quicksy_Country.h" +#import "MLConstants.h" + +@interface Quicksy_Country() + @property (nonatomic, strong) NSString* _Nullable name; //has to be optional because we don't want to have NSLocalizedString() if we know the alpha-2 code + @property (nonatomic, strong) NSString* _Nullable alpha2; //has to be optional because the alpha-2 mapping can fail + @property (nonatomic, strong) NSString* code; + @property (nonatomic, strong) NSString* pattern; +@end + +@implementation Quicksy_Country + +-(instancetype) initWithName:(NSString* _Nullable) name alpha2:(NSString* _Nullable) alpha2 code:(NSString*) code pattern:(NSString*) pattern; +{ + self = [super init]; + self.name = name; + self.alpha2 = alpha2; + self.code = code; + self.pattern = pattern; + return self; +} + +-(NSString*) id +{ + return [NSString stringWithFormat:@"%@|%@", nilDefault(self.name, @""), nilDefault(self.alpha2, @"")]; +} + +-(NSString*) description +{ + return [NSString stringWithFormat:@"%@ (%@) --> %@", nilDefault(self.name, nilDefault(self.alpha2, @"")), self.code, self.pattern]; +} + ++(BOOL) supportsSecureCoding +{ + return YES; +} + +-(void) encodeWithCoder:(NSCoder*) coder +{ + [coder encodeObject:self.name forKey:@"name"]; + [coder encodeObject:self.alpha2 forKey:@"alpha2"]; + [coder encodeObject:self.code forKey:@"code"]; + [coder encodeObject:self.pattern forKey:@"pattern"]; +} + +-(instancetype) initWithCoder:(NSCoder*) coder +{ + self = [self init]; + self.name = [coder decodeObjectForKey:@"name"]; + self.alpha2 = [coder decodeObjectForKey:@"alpha2"]; + self.code = [coder decodeObjectForKey:@"code"]; + self.pattern = [coder decodeObjectForKey:@"pattern"]; + return self; +} + +@end diff --git a/Monal/Classes/Quicksy_RegisterAccount.swift b/Monal/Classes/Quicksy_RegisterAccount.swift new file mode 100644 index 0000000..726c5bf --- /dev/null +++ b/Monal/Classes/Quicksy_RegisterAccount.swift @@ -0,0 +1,506 @@ +// +// Quicksy_RegisterAccount.swift +// Monal +// +// Created by Thilo Molitor on 13.07.24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +let DEFAULT_REGION_CODE = "en"; +let QUICKSY_BASE_URL = "https://api.quicksy.im"; + +func sendSMSRequest(to number:String) -> Promise<(data: Data, response: URLResponse)> { + var rq = URLRequest(url: URL(string: "\(QUICKSY_BASE_URL)/authentication/\(number)")!) + rq.httpMethod = "GET" + rq.addValue(Locale.current.language.languageCode?.identifier ?? DEFAULT_REGION_CODE, forHTTPHeaderField: "Accept-Language") + rq.addValue(UIDevice.current.identifierForVendor?.uuidString.lowercased() ?? UUID().uuidString.lowercased(), forHTTPHeaderField: "Installation-Id") + rq.addValue("Quicksy-iOS/\(Bundle.main.infoDictionary!["CFBundleShortVersionString"]!)", forHTTPHeaderField: "User-Agent") + DDLogDebug("Request: \(String(describing:rq))") + if let headers = rq.allHTTPHeaderFields { + for (key, value) in headers { + DDLogDebug("Header: \(key): \(value)") + } + } + return firstly { + URLSession.shared.dataTask(.promise, with: rq).validate() + } +} + +func sendRegisterRequest(number:String, pin:String, password:String) -> Promise<(data: Data, response: URLResponse)> { + var rq = URLRequest(url: URL(string: "\(QUICKSY_BASE_URL)/password")!) + rq.httpMethod = "POST" + rq.addValue(HelperTools.encodeBase64(with:"\(number)\0\(pin)"), forHTTPHeaderField: "Authorization") + rq.addValue("Quicksy-iOS/\(Bundle.main.infoDictionary!["CFBundleShortVersionString"]!)", forHTTPHeaderField: "User-Agent") + rq.httpBody = password.data(using:.utf8) + DDLogDebug("Request: \(String(describing:rq))") + if let headers = rq.allHTTPHeaderFields { + for (key, value) in headers { + DDLogDebug("Header: \(key): \(value)") + } + } + return firstly { + URLSession.shared.dataTask(.promise, with: rq).validate() + } +} + +class Quicksy_State: ObservableObject { + @defaultsDB("Quicksy_phoneNumber") + var phoneNumber: String? + + @defaultsDB("Quicksy_country") + var country: Quicksy_Country? +} + +struct Quicksy_RegisterAccount: View { + var delegate: SheetDismisserProtocol + var countries: [Quicksy_Country] = [] + @StateObject private var overlay = LoadingOverlayState() + @ObservedObject var state = Quicksy_State() + @State private var currentIndex = 0 + @State var selectedCountry: Quicksy_Country? + @State var phoneNumber: String = "" + @FocusState var phoneNumberFocused: Bool + @State var showPhoneNumberCheckAlert: String? + @State var pin: String = "" + @FocusState var pinFocused: Bool + @State var showErrorAlert: PMKHTTPError? + @State var showBackAlert: Bool? + + //login state + @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close")) + @State private var showAlert = false + @State var currentTimeout : DispatchTime? = nil + @State var errorObserverEnabled = false + @State var newAccountID: NSNumber? = nil + @State var loginComplete = false + @State var isLoadingOmemoBundles = false + + init(delegate: SheetDismisserProtocol) { + self.delegate = delegate + var countries = COUNTRY_CODES as! [Quicksy_Country] + countries.sort { + country2name($0) < country2name($1) + } + self.countries = countries + } + + private func requestSMS(for number:String) { + showPhoneNumberCheckAlert = nil + showPromisingLoadingOverlay(overlay, headline:NSLocalizedString("Requesting validation SMS...", comment: ""), description: "") { + sendSMSRequest(to:number) + }.done { data, response in + DDLogDebug("Got sendSMSRequest success: \(String(describing:response))\n\(String(describing:data))") + state.phoneNumber = number + state.country = selectedCountry //used to add a country code to phonebook entries not having any + }.catch { error in + DDLogError("Catched sendSMSRequest error: \(String(describing:error))") + if let response = error as? PMKHTTPError { + showErrorAlert = response + } + } + } + + private func createAccount() { + let password = HelperTools.generateRandomPassword() + if let number = state.phoneNumber, let _ = state.country { + showPromisingLoadingOverlay(overlay, headline:NSLocalizedString("Registering account...", comment: ""), description: "") { + sendRegisterRequest(number:number, pin:pin, password:password) + }.done { result in + DDLogDebug("Got sendRegisterRequest success: \(String(describing:result))") + startLoginTimeout() + showLoadingOverlay(overlay, headline:NSLocalizedString("Logging in", comment: "")) + self.errorObserverEnabled = true + //check if account is already configured and reset its password and its enabled and needs_password_migration states + if let newAccountID = DataLayer.sharedInstance().accountID(forUser:number, andDomain:"quicksy.im") { + self.newAccountID = newAccountID + var accountDict = DataLayer.sharedInstance().details(forAccount:newAccountID) as! [String:AnyObject] + accountDict["needs_password_migration"] = NSNumber(value:false) + accountDict["enabled"] = NSNumber(value:true) + DDLogDebug("Updating account in DB: enabled=\(String(describing:accountDict["enabled"])), needs_password_migration=\(String(describing:accountDict["needs_password_migration"])), password.count=\(password.count)") + DataLayer.sharedInstance().updateAccoun(with:accountDict) + MLXMPPManager.sharedInstance().updatePassword(password, forAccount:newAccountID) + DDLogDebug("Connecting successfully recovered and enabled account...") + MLXMPPManager.sharedInstance().connectAccount(newAccountID) + } else { + self.newAccountID = MLXMPPManager.sharedInstance().login("\(number)@quicksy.im", password: password) + if(self.newAccountID == nil) { + unreachable("Account already configured? This should never happen!") + } + } + }.catch { error in + DDLogError("Catched sendRegisterRequest error: \(String(describing:error))") + if let response = error as? PMKHTTPError { + showErrorAlert = response + } + } + } + } + + private func country2name(_ country: Quicksy_Country) -> String { + if let name = country.name { + return name + } + if let alpha2 = country.alpha2 { + if let name = Locale.current.localizedString(forRegionCode: alpha2) { + return name + } + } + unreachable("Invalid country: \(String(describing:country))") + } + + private var isValidNumber: Bool { + guard let selectedCountry = selectedCountry else { + return false + } + let phonePredicate = NSPredicate(format: "SELF MATCHES %@", selectedCountry.pattern) + return phoneNumber.allSatisfy { $0.isNumber } && phoneNumber.count > 0 && phonePredicate.evaluate(with: phoneNumber) + } + + private func showTimeoutAlert() { + DDLogVerbose("Showing timeout alert...") + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Timeout Error") + alertPrompt.message = Text("We were not able to connect your account. Please check your username and password and make sure you are connected to the internet.") + showAlert = true + } + + private func showSuccessAlert() { + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Success!") + alertPrompt.message = Text("Quicksy is now set up and connected.") + showAlert = true + } + + private func showLoginErrorAlert(errorMessage: String) { + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Error") + alertPrompt.message = Text(String(format: NSLocalizedString("We were not able to connect your account. Please check your username and password and make sure you are connected to the internet.\n\nTechnical error message: %@", comment: ""), errorMessage)) + showAlert = true + } + + private func startLoginTimeout() { + let newTimeout = DispatchTime.now() + 30.0; + self.currentTimeout = newTimeout + DispatchQueue.main.asyncAfter(deadline: newTimeout) { + if(newTimeout == self.currentTimeout) { + DDLogWarn("First login timeout triggered...") + if(self.newAccountID != nil) { + DDLogVerbose("Removing account...") + MLXMPPManager.sharedInstance().removeAccount(forAccountID: self.newAccountID!) + self.newAccountID = nil + } + self.currentTimeout = nil + showTimeoutAlert() + } + } + } + + var body: some View { + ZStack { + /// Ensure the ZStack takes the entire area + Color.clear + + if state.phoneNumber == nil || state.country == nil { + VStack(alignment: .leading) { + Text("") + + Text("Verify your phone number") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 8) + + Text("Quicksy will send an SMS message (carrier charges may apply) to verify your phone number. Enter your country code and phone number:") + + HStack { + Text("Country:") + Picker(selection: $selectedCountry, label: EmptyView()) { + ForEach(countries) { country in + Text("\(country2name(country)) (\(country.code))").tag(country as Quicksy_Country?) + } + } + .pickerStyle(MenuPickerStyle()) + } + + HStack { + if let selectedCountry = selectedCountry { + Text(selectedCountry.code) + } + TextField("Phone Number", text: $phoneNumber) + .focused($phoneNumberFocused) + .keyboardType(.numberPad) + .onChange(of: phoneNumber) { newValue in + let filtered = newValue.filter { "0123456789".contains($0) } + if filtered != newValue { + phoneNumber = filtered + } + } + } + .padding() + .border(phoneNumber.count==0 ? Color.gray : (isValidNumber ? Color.green : Color.red), width: phoneNumber.count==0 ? 1 : 2) + + Spacer() + + if let selectedCountry = selectedCountry { + HStack { + Spacer() + + Button(action: { + showPhoneNumberCheckAlert = selectedCountry.code + phoneNumber + }) { + Text("Next") + } + .disabled(!isValidNumber) + .buttonStyle(MonalProminentButtonStyle()) + } + } + } + .richAlert(isPresented:$showPhoneNumberCheckAlert, title:Text("Check this number?"), body:{ number in + VStack(alignment: .leading) { + Text("We will check the number **\(number)**. Is this okay or do you want to change the number?") + } + }, buttons: { number in + HStack { + Button(action: { + showPhoneNumberCheckAlert = nil + phoneNumberFocused = true + }) { + Text("Change it") + } + .buttonStyle(MonalProminentButtonStyle()) + + Spacer() + + Button(action: { + requestSMS(for:number) + }) { + Text("OK") + } + .buttonStyle(MonalProminentButtonStyle()) + } + }) + .onAppear { + let regionCode = Locale.current.region?.identifier ?? DEFAULT_REGION_CODE + selectedCountry = countries[0] + DDLogInfo("Localization: using regionCode: \(String(describing:regionCode))") + DDLogInfo("Localization: current locale localized string for regionCode: \(String(describing:Locale.current.localizedString(forRegionCode:regionCode)))") + DDLogInfo("Localization: en_US localized string for regionCode: \(String(describing:Locale(identifier: "en_US").localizedString(forRegionCode:regionCode)))") + DDLogInfo("Previous country: \(String(describing:state.country))") + for country in countries { + if let previousCountry = state.country { + //check alpha2 code and country name explicitly to still match even when changing other properties + if (previousCountry.alpha2 != nil && previousCountry.alpha2 == country.alpha2) || (previousCountry.name != nil && previousCountry.name == country.name) { + DDLogInfo("Selecting country from previous: \(String(describing:country))") + selectedCountry = country + break + } + } else if country.alpha2 == regionCode || country.name == Locale.current.localizedString(forRegionCode:regionCode) || country.name == Locale(identifier: "en_US").localizedString(forRegionCode:regionCode) { + DDLogInfo("Selecting country from locale: \(String(describing:country))") + selectedCountry = country + break + } + } + DDLogInfo("Finally preselected country: \(String(describing:selectedCountry))") + phoneNumberFocused = true + } + } else if let number = state.phoneNumber, let _ = state.country { + VStack(alignment: .leading) { + Text("") + + Text("Verify your phone number") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 8) + + Text("We sent you an SMS to **\(number)**") + Text("Please enter the six-digit pin below") + HStack { + TextField("Pin", text: $pin) + .focused($phoneNumberFocused) + .keyboardType(.numberPad) + .onChange(of: pin) { newValue in + let filtered = newValue.filter { "0123456789".contains($0) } + if filtered != newValue { + pin = filtered + } + } + } + .padding() + .border(pin.count==0 ? Color.gray : (pin.count==6 ? Color.green : Color.red), width: pin.count==0 ? 1 : 2) + + Spacer().frame(height:16) + + Button(action: { + requestSMS(for:number) + }) { + Text("Send SMS again") + } + .frame(maxWidth: .infinity, alignment: .center).padding() + + Spacer() + + HStack { + Button(action: { + showBackAlert = true + }) { + Text("Previous") + } + .buttonStyle(MonalProminentButtonStyle()) + + Spacer() + + Button(action: { + createAccount() + }) { + Text("Next") + } + .buttonStyle(MonalProminentButtonStyle()) + } + } + .richAlert(isPresented:$showBackAlert, title:Text("Cancel?")) { error in + VStack(alignment: .leading) { + Text("Are you sure to cancel the registration process?") + } + } buttons: { error in + HStack { + Button(action: { + showBackAlert = nil + }) { + Text("No") + } + .buttonStyle(MonalProminentButtonStyle()) + + Spacer() + + Button(action: { + showBackAlert = nil + state.phoneNumber = nil + }) { + Text("Yes") + } + .buttonStyle(MonalProminentButtonStyle()) + } + } + .onAppear { + pinFocused = true + } + } else { + unreachable("quicksy registration out of ui options!") + } + } + .alert(isPresented: $showAlert) { + Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton: .default(alertPrompt.dismissLabel, action: { + if(self.loginComplete == true) { + self.delegate.dismissWithoutAnimation() + } + })) + } + .richAlert(isPresented:$showErrorAlert, title:Text("Error requesting SMS!"), body:{ error in + VStack(alignment: .leading) { + Text("An error happened when trying to request the SMS:") + .bold() + Spacer().frame(height:16) + switch error { + case .badStatusCode(let code, _, let response): + switch code { + case 400: + Text("Invalid user input.") + case 401: + Text("The pin you have entered is incorrect.") + case 403: + Text("You are using an out of date version of this app.") + case 404: + Text("The pin we have sent you has expired.") + case 409: + Text("This phone number is currently logged in with another device.") + case 429: + Text("Too many attempts, please try again in \(HelperTools.string(fromTimeInterval:UInt(response.value(forHTTPHeaderField:"Retry-After") ?? "0") ?? 0)).") + case 500: + Text("Something went wrong processing your request.") + case 501: + Text("Temporarily unavailable. Try again later.") + case 502: + Text("Temporarily unavailable. Try again later.") + case 503: + Text("Temporarily unavailable. Try again later.") + default: + Text("Unexpected error processing your request.") + } + } + } + }, buttons: { error in + HStack { + Spacer() + + Button(action: { + showErrorAlert = nil + }) { + Text("OK") + } + .buttonStyle(MonalProminentButtonStyle()) + } + }) + .padding() + .addLoadingOverlay(overlay) + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kXMPPError")).receive(on: RunLoop.main)) { notification in + if(self.errorObserverEnabled == false) { + return + } + if let xmppAccount = notification.object as? xmpp, let newAccountID : NSNumber = self.newAccountID, let errorMessage = notification.userInfo?["message"] as? String { + if(xmppAccount.accountID.intValue == newAccountID.intValue) { + DispatchQueue.main.async { + currentTimeout = nil // <- disable timeout on error + errorObserverEnabled = false + showLoginErrorAlert(errorMessage: errorMessage) + MLXMPPManager.sharedInstance().removeAccount(forAccountID: newAccountID) + self.newAccountID = nil + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMLResourceBoundNotice")).receive(on: RunLoop.main)) { notification in + if let xmppAccount = notification.object as? xmpp, let newAccountID : NSNumber = self.newAccountID { + if(xmppAccount.accountID.intValue == newAccountID.intValue) { + DispatchQueue.main.async { + currentTimeout = nil // <- disable timeout on successful connection + self.errorObserverEnabled = false + showLoadingOverlay(overlay, headline:NSLocalizedString("Loading contact list", comment: "")) + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalUpdateBundleFetchStatus")).receive(on: RunLoop.main)) { notification in + if let notificationAccountID = notification.userInfo?["accountID"] as? NSNumber, let completed = notification.userInfo?["completed"] as? NSNumber, let all = notification.userInfo?["all"] as? NSNumber, let newAccountID : NSNumber = self.newAccountID { + if(notificationAccountID.intValue == newAccountID.intValue) { + isLoadingOmemoBundles = true + showLoadingOverlay( + overlay, + headline:NSLocalizedString("Loading omemo bundles", comment: ""), + description:String(format: NSLocalizedString("Loading omemo bundles: %@ / %@", comment: ""), completed, all) + ) + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalFinishedOmemoBundleFetch")).receive(on: RunLoop.main)) { notification in + if let notificationAccountID = notification.userInfo?["accountID"] as? NSNumber, let newAccountID : NSNumber = self.newAccountID { + if(notificationAccountID.intValue == newAccountID.intValue && isLoadingOmemoBundles) { + DispatchQueue.main.async { + self.loginComplete = true + showSuccessAlert() + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalFinishedCatchup")).receive(on: RunLoop.main)) { notification in + if let xmppAccount = notification.object as? xmpp, let newAccountID : NSNumber = self.newAccountID { + if(xmppAccount.accountID.intValue == newAccountID.intValue && !isLoadingOmemoBundles) { + DispatchQueue.main.async { + self.loginComplete = true + showSuccessAlert() + } + } + } + } + } +} diff --git a/Monal/Classes/RegisterAccount.swift b/Monal/Classes/RegisterAccount.swift new file mode 100644 index 0000000..140c110 --- /dev/null +++ b/Monal/Classes/RegisterAccount.swift @@ -0,0 +1,508 @@ +// +// RegisterAccount.swift +// Monal +// +// Created by CC on 22.04.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +import SafariServices +import WebKit +import FrameUp + +struct WebView: UIViewRepresentable { + var url: URL + + func makeUIView(context: Context) -> WKWebView { + return WKWebView() + } + + func updateUIView(_ webView: WKWebView, context: Context) { + var request = URLRequest(url: url) + if HelperTools.defaultsDB().bool(forKey:"useDnssecForAllConnections") { + request.requiresDNSSECValidation = true; + } + webView.load(request) + } +} + +struct RegisterAccount: View { + static private let xmppFaultyPattern = ".+\\..{2,}$" + static private let credFaultyPattern = ".*@.*" + static private let XMPPServer: [Dictionary] = [ + ["XMPPServer": "Input", "TermsSite_default": ""], + ["XMPPServer": "conversations.im", "TermsSite_default": "https://account.conversations.im/privacy/"], + ["XMPPServer": "yax.im", "TermsSite_default": "https://yaxim.org/yax.im/"] + ] + + @State private var username: String = "" + @State private var password: String = "" + @State private var repeatedPassword: String = "" + @State private var registerToken: String? + @State private var completionHandler:((AnyObject?)->Void)? + + @State private var providedServer: String = "" + @State private var selectedServerIndex = Int.random(in: 1 ..< XMPPServer.count) + + @State private var showAlert = false + @State private var registerComplete = false + @State private var registeredAccountID = -1 + + @State private var xmppAccount: xmpp? + @State private var captchaImg: Image? + @State private var hiddenFields: Dictionary? + @State private var captchaText: String = "" + + @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close")) + @StateObject private var overlay = LoadingOverlayState() + @State private var currentTimeout : DispatchTime? = nil + + @State private var showWebView = false + @State private var errorObserverEnabled = false + + var delegate: SheetDismisserProtocol + + init(delegate: SheetDismisserProtocol, registerData:[String:AnyObject]? = nil) { + self.delegate = delegate + if let registerData = registerData { + DDLogDebug("RegisterAccount created with data: \(registerData)"); + //for State stuff see https://forums.swift.org/t/assignment-to-state-var-in-init-doesnt-do-anything-but-the-compiler-gened-one-works/35235 + self._selectedServerIndex = State(wrappedValue:0) + self._providedServer = State(wrappedValue:(registerData["host"] as? String) ?? "") + self._username = State(wrappedValue:(registerData["username"] as? String) ?? "") + self._registerToken = State(wrappedValue:registerData["token"] as? String) + if let completion = registerData["completion"] { + self._completionHandler = State(wrappedValue:objcCast(completion) as monal_id_block_t) + } + DDLogVerbose("registerToken is now: \(String(describing:self.registerToken))") + DDLogVerbose("Completion handler is now: \(String(describing:self.completionHandler))") + } + } + + private var serverSelectedAlert: Bool { + alertPrompt.title = Text("No XMPP server!") + alertPrompt.message = Text("Please select a XMPP server or provide one.") + return serverSelected + } + + private var serverProvidedAlert: Bool { + alertPrompt.title = Text("No XMPP server!") + alertPrompt.message = Text("Please select a XMPP server or provide one.") + return serverProvided + } + + private var xmppServerFaultyAlert: Bool { + alertPrompt.title = Text("XMPP server domain not valid!") + alertPrompt.message = Text("Please provide a valid XMPP server domain or select one.") + return xmppServerFaulty + } + + private var credentialsEnteredAlert: Bool { + alertPrompt.title = Text("No Empty Values!") + alertPrompt.message = Text("Please make sure you have entered a username, password.") + return credentialsEntered + } + + private var passwordsMatchAlert: Bool { + alertPrompt.title = Text("Passwords don't match!") + alertPrompt.message = Text("Please make sure you have entered the same password in both password fields.") + return passwordsMatch + } + + private var credentialsFaultyAlert: Bool { + alertPrompt.title = Text("Invalid Username!") + alertPrompt.message = Text("The username does not need to have an @ symbol. Please try again.") + return credentialsFaulty + } + + private var credentialsExistAlert: Bool { + alertPrompt.title = Text("Duplicate Account!") + alertPrompt.message = Text("This account already exists on this instance.") + return credentialsExist + } + + private func showRegistrationAlert(alertMessage: String?) { + alertPrompt.title = Text("Registration Error") + alertPrompt.message = Text(alertMessage ?? NSLocalizedString("Could not register your username. Please check your code or change the username and try again.", comment: "")) + hideLoadingOverlay(overlay) + showAlert = true + } + + private func showSuccessAlert() { + alertPrompt.title = Text("Success!") + alertPrompt.message = Text("You are set up and connected. People can message you at: \(self.username)@\(self.actualServer)") + hideLoadingOverlay(overlay) + showAlert = true + } + + private var actualServer: String { + let tmp = RegisterAccount.XMPPServer[$selectedServerIndex.wrappedValue]["XMPPServer"] + return (tmp != nil && tmp != "Input") ? tmp! : serverProvided && !xmppServerFaulty ? providedServer : "?" + } + + private var serverSelected: Bool { + return selectedServerIndex != 0 + } + + private var serverProvided: Bool { + return providedServer != "" + } + + private var xmppServerFaulty: Bool { + return providedServer.range(of: RegisterAccount.xmppFaultyPattern, options:.regularExpression) == nil + } + + private var credentialsEntered: Bool { + return !username.isEmpty && !password.isEmpty + } + + private var passwordsMatch: Bool { + return password == repeatedPassword + } + + private var credentialsFaulty: Bool { + return username.range(of: RegisterAccount.credFaultyPattern, options: .regularExpression) != nil + } + + private var credentialsExist: Bool { + return DataLayer.sharedInstance().doesAccountExistUser(username, andDomain:actualServer) + } + + private var registerButtonDisabled: Bool { + return (!serverSelected && (!serverProvided || xmppServerFaulty)) || (!credentialsEntered || !passwordsMatch || credentialsFaulty || credentialsExist) + } + + private func createXMPPInstance() -> xmpp { + let identity = MLXMPPIdentity.init(jid: String.init(format: "nothing@%@", self.actualServer), password: "nothing", andResource: "MonalReg"); + let server = MLXMPPServer.init(host: "", andPort: 5222, andDirectTLS: false) + return xmpp.init(server: server, andIdentity: identity, andAccountID: -1) + } + + private func cleanupXMPPInstance() { + if(self.xmppAccount != nil) { + DDLogDebug("Disconnecting registering xmpp account...") + self.xmppAccount!.disconnect(true) + } + self.xmppAccount = nil; + } + + private func register() { + showLoadingOverlay(overlay, headline:NSLocalizedString("Registering account...", comment: "")) + if(self.xmppAccount == nil) { + self.xmppAccount = createXMPPInstance() + } + self.xmppAccount!.registerUser(self.username, withPassword: self.password, captcha: self.captchaText.isEmpty == true ? nil : self.captchaText, andHiddenFields: self.hiddenFields) {success, errorMsg in + DispatchQueue.main.async { + if(success == true) { + let dic = [ + kDomain: self.actualServer, + kUsername: self.username, + kResource: HelperTools.encodeRandomResource(), + kEnabled: true, + kDirectTLS: false, + //creating an account involves transfering the password in cleartext only secured by TLS + //--> logging in directly afterwards using PLAIN doesn't make the situation any worse ==> allow it + //conversations.im already supports sasl2 and scram ## TODO: use SCRAM preload list + //using the preload list in this case won't solve the situation, but increase the attack cost because + //stripping off SASL2 won't suffice anymore (the attacker will have to use the password sniffed during account creation + //to fake the SCRAM HMAC sent to both client and server) + kPlainActivated: self.actualServer == "conversations.im" ? false : true, + ] as [String : Any] + + let accountID = DataLayer.sharedInstance().addAccount(with: dic); + if(accountID != nil) { + self.registeredAccountID = accountID!.intValue + MLXMPPManager.sharedInstance().addNewAccountToKeychainAndConnect(withPassword:self.password, andAccountID:accountID!) + cleanupXMPPInstance() + } else { + cleanupXMPPInstance() + showRegistrationAlert(alertMessage:NSLocalizedString("Account already configured in Monal!", comment: "")) + self.captchaText = "" + if(self.captchaImg != nil) { + fetchRequestForm() // < force reload the form to update the captcha + } + } + } else { + cleanupXMPPInstance() + showRegistrationAlert(alertMessage:errorMsg) + self.captchaText = "" + if(self.captchaImg != nil) { + fetchRequestForm() // < force reload the form to update the captcha + } + } + } + } + } + + private func fetchRequestForm() { + //dispatch after 50ms because otherwise we get an "Modifying state during view update, this will cause undefined behaviour" error + //undefined in our case seems to mean: we get only the blurring effect but the loading overlay will only be shown after an ui update + //update: we still get this error even when using this timeout, but at least the ui is rendered properly + let newTimeout = DispatchTime.now() + 0.05 + self.currentTimeout = newTimeout + DispatchQueue.main.asyncAfter(deadline: newTimeout) { + if(newTimeout == self.currentTimeout) { + showLoadingOverlay(overlay, headline:NSLocalizedString("Fetching registration form...", comment: "")) + if(self.xmppAccount != nil) { + self.xmppAccount!.disconnect(true) + } + self.xmppAccount = createXMPPInstance() + self.xmppAccount!.requestRegForm(withToken: self.registerToken, andCompletion: {captchaData, hiddenFieldsDict in + DispatchQueue.main.async { + self.hiddenFields = hiddenFieldsDict + if(captchaData.isEmpty == true) { + register() + } else { + //only disconnect if waiting for captcha input (to make sure we don't get any spurious timeout errors from the server) + if(self.xmppAccount != nil) { + self.xmppAccount!.disconnect(true) + self.xmppAccount = nil + } + hideLoadingOverlay(overlay) + let captchaUIImg = UIImage.init(data: captchaData) + if(captchaUIImg != nil) { + self.captchaImg = Image(uiImage: captchaUIImg!) + } else { + cleanupXMPPInstance() + showRegistrationAlert(alertMessage: NSLocalizedString("Could not read captcha!", comment: "")) + } + } + } + }, andErrorCompletion: {_, errorMsg in + DispatchQueue.main.async { + cleanupXMPPInstance() + showRegistrationAlert(alertMessage: errorMsg) + } + }) + } + } + } + + private func termsSiteForCurrentLanguage() -> URL { + let languageCode = Locale.current.language.languageCode?.identifier + let chosenServer = RegisterAccount.XMPPServer[$selectedServerIndex.wrappedValue] + return URL(string: (chosenServer["TermsSite_\(languageCode ?? "default")"] ?? chosenServer["TermsSite_default"])!)! + } + + var body: some View { + ZStack { + /// Ensure the ZStack takes the entire area + Color.clear + + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading) { + VStack(alignment: .leading) { + Text("Like email, you can register your account on many sites and talk to anyone. You can use this page to register an account with a selected or provided XMPP server. You also have to choose a username and a password.") + .padding() + } + .background(Color(UIColor.systemBackground)) + + Form { + Text("I need an account:") + .listRowSeparator(.hidden) + + Menu { + Picker("", selection: $selectedServerIndex) { + ForEach (RegisterAccount.XMPPServer.indices, id: \.self) { + if($0 == 0) { + Text("Manual input").tag(0) + } + else { + Text(RegisterAccount.XMPPServer[$0]["XMPPServer"] ?? "").tag($0) + } + } + } + .onChange(of: selectedServerIndex, perform: { (_) in + self.captchaImg = nil + self.captchaText = "" + self.xmppAccount = nil + self.registerToken = nil + }) + .labelsHidden() + .pickerStyle(.inline) + } + label: { + HStack { + if(selectedServerIndex != 0) { + Text(RegisterAccount.XMPPServer[selectedServerIndex]["XMPPServer"]!).font(.system(size: 17)).frame(maxWidth: .infinity) + Image(systemName: "checkmark") + } + else { + Text("Manual input") + .font(.system(size: 17)) + .frame(maxWidth: .infinity) + } + } + .padding(9.0) + .background(Color(UIColor.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .disabled(self.registerToken != nil) + .listRowSeparator(.hidden) + + Group { + if(selectedServerIndex == 0) { + TextField(NSLocalizedString("Provide XMPP-Server", comment: "placeholder when creating account"), text: Binding( + get: { self.providedServer }, + set: { string in self.providedServer = string.lowercased().replacingOccurrences(of: " ", with: "") } + )) + .textInputAutocapitalization(.never) + .autocapitalization(.none) + .autocorrectionDisabled() + .foregroundColor(self.registerToken != nil ? .secondary : .primary) + .disabled(self.registerToken != nil) + .listRowSeparator(.hidden) + } + + TextField(NSLocalizedString("Username", comment: "placeholder when creating account"), text: Binding( + get: { self.username }, + set: { string in self.username = string.lowercased().replacingOccurrences(of: " ", with: "") } + )) + .textInputAutocapitalization(.never) + .autocapitalization(.none) + .autocorrectionDisabled() + .listRowSeparator(.hidden) + + SecureField(NSLocalizedString("Password", comment: "placeholder when creating account"), text: $password) + .listRowSeparator(.hidden) + SecureField(NSLocalizedString("Password (repeated)", comment: "placeholder when creating account"), text: $repeatedPassword) + .listRowSeparator(.hidden) + } + + if(self.captchaImg != nil) { + HStack { + self.captchaImg + Spacer() + Button(action: { + fetchRequestForm() + }, label: { + Image(systemName: "arrow.clockwise") + }) + .buttonStyle(.borderless) + } + .listRowSeparator(.hidden) + + TextField(NSLocalizedString("Captcha", comment: "placeholder when creating account"), text: $captchaText) + .textInputAutocapitalization(.never) + .autocapitalization(.none) + .autocorrectionDisabled() + .listRowSeparator(.hidden) + } + + Button(action: { + showAlert = (!serverSelectedAlert && (!serverProvidedAlert || xmppServerFaultyAlert)) || (!credentialsEnteredAlert || !passwordsMatchAlert || credentialsFaultyAlert || credentialsExistAlert) + + if(!showAlert) { + self.errorObserverEnabled = true + if(self.captchaImg == nil) { + fetchRequestForm() + } else { + register() + } + } + }){ + Text("Register with \(actualServer)") + .frame(maxWidth: .infinity) + .padding(9.0) + .background(Color(UIColor.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(BorderlessButtonStyle()) + .disabled(registerButtonDisabled) + .listRowSeparator(.hidden) + .alert(isPresented: $showAlert) { + Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton: .default(alertPrompt.dismissLabel, action: { + if(self.registerComplete == true) { + self.delegate.dismiss() + + if let completion = self.completionHandler { + DDLogVerbose("Calling reg completion handler...") + completion(self.registeredAccountID as NSNumber) + } + } + })) + } + Text("The selectable XMPP servers are public servers which are not affiliated to Monal. This registration page is provided for convenience only.") + .font(.system(size: 10)) + .padding(.vertical, 8) + + if(selectedServerIndex != 0) { + Button (action: { + showWebView.toggle() + }){ + Text("Terms of use for \(RegisterAccount.XMPPServer[$selectedServerIndex.wrappedValue]["XMPPServer"]!)") + .font(.system(size: 10)) + } + .frame(maxWidth: .infinity) + .sheet(isPresented: $showWebView) { + NavigationStack { + WebView(url: termsSiteForCurrentLanguage()) + .navigationBarTitle(Text("Terms of \(RegisterAccount.XMPPServer[$selectedServerIndex.wrappedValue]["XMPPServer"]!)"), displayMode: .inline) + .toolbar(content: { + ToolbarItem(placement: .bottomBar) { + Button (action: { + showWebView.toggle() + }){ + Text("Close") + } + } + }) + } + } + } + } + .textFieldStyle(.roundedBorder) + } + /// Sets the minimum frame height to the available height of the scrollview and the maxHeight to infinity + .frame(minHeight: proxy.size.height, maxHeight: .infinity) + } + } + } + .addLoadingOverlay(overlay) + .navigationBarTitle(Text("Register"), displayMode:.large) + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kXMPPError")).receive(on: RunLoop.main)) { notification in + DDLogDebug("Got xmpp error") + if(self.errorObserverEnabled == false) { + return + } + if let xmppAccount = notification.object as? xmpp, let errorMessage = notification.userInfo?["message"] as? String { + if(xmppAccount.accountID.intValue == self.registeredAccountID || xmppAccount.accountID.intValue == -1) { + DispatchQueue.main.async { + DDLogDebug("XMPP account matches registering one") + self.errorObserverEnabled = false + xmppAccount.disconnect(true) //disconnect account (even if not listed in enabledAccounts and having id -1) + MLXMPPManager.sharedInstance().removeAccount(forAccountID:xmppAccount.accountID) //remove from enabledAccounts and db, if listed, do nothing otherwise (e.g. in the -1 case) + //reset local state var if the account had id -1 (e.g. is dummy for registering recorded in self.xmppAccount) + if(xmppAccount == self.xmppAccount) { + self.xmppAccount = nil + } + showRegistrationAlert(alertMessage: errorMessage) + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMLResourceBoundNotice")).receive(on: RunLoop.main)) { notification in + if(self.registerComplete == true) { + return + } + if let xmppAccount = notification.object as? xmpp { + if(xmppAccount.accountID.intValue == self.registeredAccountID) { + DispatchQueue.main.async { + hideLoadingOverlay(overlay) + self.errorObserverEnabled = false + self.registerComplete = true + showSuccessAlert() + } + } + } + } + } +} + +struct RegisterAccount_Previews: PreviewProvider { + static var delegate = SheetDismisserProtocol() + static var previews: some View { + RegisterAccount(delegate:delegate) + } +} diff --git a/Monal/Classes/RichAlert.swift b/Monal/Classes/RichAlert.swift new file mode 100644 index 0000000..bd1f7af --- /dev/null +++ b/Monal/Classes/RichAlert.swift @@ -0,0 +1,165 @@ +// +// RichAlert.swift +// Monal +// +// Created by Thilo Molitor on 25.12.22. +// Copyright © 2022 monal-im.org. All rights reserved. +// + +import ViewExtractor +import FrameUp + +struct RichAlertView: ViewModifier where TitleContent: View, BodyContent: View, ButtonContent: View { + @Binding public var isPresented: T? + let alertTitle: (T) -> TitleContent + let alertBody: (T) -> BodyContent + let alertButtons: (T) -> ButtonContent + @State private var scrollViewContentSize: CGSize = .zero + + public func body(content: Content) -> some View { + return ZStack(alignment: .center) { + Color(UIColor.systemGroupedBackground).ignoresSafeArea() + + content + .disabled(isPresented != nil) + .blur(radius:(isPresented != nil ? 3 : 0)) + + if let data:T = isPresented { + VStack { + alertTitle(data) + .font(.headline) + .padding([.leading, .trailing], 24) + Divider() + SmartScrollView(.vertical, showsIndicators: true, optionalScrolling: true, shrinkToFit: true) { + VStack { + alertBody(data) + .padding([.leading, .trailing], 24) + } + } + let buttonViews = alertButtons(data) + Extract(buttonViews) { views in + if views.count == 0 || buttonViews is EmptyView { + Divider() + Button("Close") { + isPresented = nil + } + .padding([.leading, .trailing], 24) + .buttonStyle(DefaultButtonStyle()) + } else { + ForEach(views) { view in + Divider() + .padding(0) + view + .padding([.leading, .trailing], 24) + .buttonStyle(DefaultButtonStyle()) + } + } + } + } + .foregroundColor(.primary) + .padding([.top, .bottom], 13) + .frame(width: 320) + .background(Color.background) + .cornerRadius(16) + .shadow(color: Color.primary.opacity(0.4), radius: 16, x: 0, y: 0) + .padding([.top, .bottom], 24) + } + } + .transition(.opacity) + } +} + +//this contains all possible variants to use this (view builders don't seem to be able to take default arguments :/ ) +extension View { + //title(X), body(X), (buttons) + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping (_ data: T) -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:title, alertBody:body, alertButtons:{ _ in EmptyView() })) + } + func richAlert(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:{ _ in EmptyView() })) + } + //title(), body(X), (buttons) + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:{ _ in EmptyView() })) + } + + //title(X), body(), (buttons) + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping (_ data: T) -> some View, @ViewBuilder body: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:title, alertBody:{ _ in body() }, alertButtons:{ _ in EmptyView() })) + } + func richAlert(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:{ _ in body() }, alertButtons:{ _ in EmptyView() })) + } + //title(), body(), (buttons) + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping () -> some View, @ViewBuilder body: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:{ _ in body() }, alertButtons:{ _ in EmptyView() })) + } + + + //title(X), body(X), buttons(X) + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping (_ data: T) -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:title, alertBody:body, alertButtons:buttons)) + } + func richAlert(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:buttons)) + } + //apparently this is sometimes somehow needed to not confuse the compiler into using some of the other functions instead of this + //(it tries to use the title(), body(), buttons(X) variant in Quicksy_RegisterAccount) + func richAlertX(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:buttons)) + } + //title(), body(X), buttons(X) + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:buttons)) + } + + //title(X), body(), buttons(X) + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping (_ data: T) -> some View, @ViewBuilder body: @escaping () -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:title, alertBody:{ _ in body() }, alertButtons:buttons)) + } + func richAlert(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping () -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:{ _ in body() }, alertButtons:buttons)) + } + //title(), body(), buttons(X) + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping () -> some View, @ViewBuilder body: @escaping () -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:{ _ in body() }, alertButtons:buttons)) + } + + + //title(X), body(X), buttons() + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping (_ data: T) -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:title, alertBody:body, alertButtons:{ _ in buttons() })) + } + func richAlert(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:{ _ in buttons() })) + } + //title(), body(X), buttons() + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:{ _ in buttons() })) + } + + //title(X), body(), buttons() + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping (_ data: T) -> some View, @ViewBuilder body: @escaping () -> some View, @ViewBuilder buttons: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:title, alertBody:{ _ in body() }, alertButtons:{ _ in buttons() })) + } + func richAlert(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping () -> some View, @ViewBuilder buttons: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:{ _ in body() }, alertButtons:{ _ in buttons() })) + } + //title(), body(), buttons() + func richAlert(isPresented: Binding, @ViewBuilder title: @escaping () -> some View, @ViewBuilder body: @escaping () -> some View, @ViewBuilder buttons: @escaping () -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:{ _ in body() }, alertButtons:{ _ in buttons() })) + } +} + + +struct RichAlert_Previews: PreviewProvider { + static var previews: some View { + Color.clear + .richAlert(isPresented:Binding(get:{true}, set:{_ in}), title:Text("Cool Title")) { + VStack { + Text("Rich Text") + Text("BODY") + } + } + } +} diff --git a/Monal/Classes/SCRAM.h b/Monal/Classes/SCRAM.h new file mode 100644 index 0000000..a4fce09 --- /dev/null +++ b/Monal/Classes/SCRAM.h @@ -0,0 +1,48 @@ +// +// SCRAM.h +// Monal +// +// Created by Thilo Molitor on 05.08.22. +// Copyright © 2022 monal-im.org. All rights reserved. +// + +#ifndef SCRAM_h +#define SCRAM_h + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSUInteger, MLScramStatus) { + //server-first-message + MLScramStatusNonceError, + MLScramStatusUnsupportedMAttribute, + MLScramStatusSSDPTriggered, + MLScramStatusIterationCountInsecure, + MLScramStatusServerFirstOK, + //server-final-message + MLScramStatusWrongServerProof, + MLScramStatusServerError, + MLScramStatusServerFinalOK, +}; + +@interface SCRAM : NSObject ++(NSArray*) supportedMechanismsIncludingChannelBinding:(BOOL) include; +-(instancetype) initWithUsername:(NSString*) username password:(NSString*) password andMethod:(NSString*) method; +-(void) setSSDPMechanisms:(NSArray*) mechanisms andChannelBindingTypes:(NSArray* _Nullable) cbTypes; + +-(NSString*) clientFirstMessageWithChannelBinding:(NSString* _Nullable) channelBindingType; +-(MLScramStatus) parseServerFirstMessage:(NSString*) str; +-(NSString*) clientFinalMessageWithChannelBindingData:(NSData* _Nullable) channelBindingData; +-(MLScramStatus) parseServerFinalMessage:(NSString*) str; +-(NSData*) hashPasswordWithSalt:(NSData*) salt andIterationCount:(uint32_t) iterationCount; + +@property (nonatomic, readonly) NSString* method; +@property (nonatomic, readonly) BOOL serverFirstMessageParsed; +@property (nonatomic, readonly) BOOL finishedSuccessfully; +@property (nonatomic, readonly) BOOL ssdpSupported; + ++(void) SSDPXepOutput; +@end + +NS_ASSUME_NONNULL_END + +#endif /* SCRAM_h */ diff --git a/Monal/Classes/SCRAM.m b/Monal/Classes/SCRAM.m new file mode 100644 index 0000000..178eb0f --- /dev/null +++ b/Monal/Classes/SCRAM.m @@ -0,0 +1,907 @@ +// +// SCRAM.m +// monalxmpp +// +// Created by Thilo Molitor on 05.08.22. +// Copyright © 2022 monal-im.org. All rights reserved. +// + +#include + +#import +#import "HelperTools.h" +#import "SCRAM.h" + +@interface SCRAM () +{ + BOOL _usingChannelBinding; + NSString* _method; + NSString* _username; + NSString* _password; + NSString* _nonce; + NSString* _ssdpString; + + NSString* _clientFirstMessageBare; + NSString* _gssHeader; + + NSString* _serverFirstMessage; + uint32_t _iterationCount; + NSData* _salt; + + NSString* _expectedServerSignature; +} +@end + +//see these for intermediate test values: +//https://stackoverflow.com/a/32470299/3528174 +//https://stackoverflow.com/a/29299946/3528174 +@implementation SCRAM + +//list supported mechanisms (highest security first!) ++(NSArray*) supportedMechanismsIncludingChannelBinding:(BOOL) include +{ + if(include) + return @[@"SCRAM-SHA-512-PLUS", @"SCRAM-SHA-256-PLUS", @"SCRAM-SHA-1-PLUS", @"SCRAM-SHA-512", @"SCRAM-SHA-256", @"SCRAM-SHA-1"]; + return @[@"SCRAM-SHA-512", @"SCRAM-SHA-256", @"SCRAM-SHA-1"]; +} + +-(instancetype) initWithUsername:(NSString*) username password:(NSString*) password andMethod:(NSString*) method +{ + self = [super init]; + MLAssert([[[self class] supportedMechanismsIncludingChannelBinding:YES] containsObject:method], @"Unsupported SCRAM hash method!", (@{@"method": nilWrapper(method)})); + _usingChannelBinding = [@"-PLUS" isEqualToString:[method substringFromIndex:method.length-5]]; + if(_usingChannelBinding) + _method = [method substringWithRange:NSMakeRange(0, method.length-5)]; + else + _method = method; + _username = username; + _password = [self SASLPrep:password isQuery:NO]; + if(password.length>0 && _password.length==0) + DDLogError(@"SASLPrep failed for password, using empty password!"); + _nonce = [NSUUID UUID].UUIDString; + _ssdpString = nil; + _serverFirstMessageParsed = NO; + _finishedSuccessfully = NO; + return self; +} + +-(void) setSSDPMechanisms:(NSArray*) mechanisms andChannelBindingTypes:(NSArray* _Nullable) cbTypes +{ + MLAssert(!_finishedSuccessfully, @"SCRAM handler finished already!"); + MLAssert(!_serverFirstMessageParsed, @"SCRAM handler already parsed server-first-message!"); + DDLogVerbose(@"Creating SDDP string: %@\n%@", mechanisms, cbTypes); + NSMutableString* ssdpString = [NSMutableString new]; + [ssdpString appendString:[[mechanisms sortedArrayUsingSelector:@selector(compare:)] componentsJoinedByString:@","]]; + if(cbTypes != nil) + { + [ssdpString appendString:@"|"]; + [ssdpString appendString:[[cbTypes sortedArrayUsingSelector:@selector(compare:)] componentsJoinedByString:@","]]; + } + _ssdpString = [ssdpString copy]; + DDLogVerbose(@"SDDP string is now: %@", _ssdpString); +} + +-(NSString*) clientFirstMessageWithChannelBinding:(NSString* _Nullable) channelBindingType +{ + MLAssert(!_finishedSuccessfully, @"SCRAM handler finished already!"); + MLAssert(!_serverFirstMessageParsed, @"SCRAM handler already parsed server-first-message!"); + if(channelBindingType == nil) + _gssHeader = @"n,,"; //not supported by us + else if(!_usingChannelBinding) + _gssHeader = @"y,,"; //supported by us BUT NOT advertised by the server + else + _gssHeader = [NSString stringWithFormat:@"p=%@,,", channelBindingType]; //supported by us AND advertised by the server + //the g attribute is a random grease to check if servers are rfc compliant (e.g. accept optional attributes) + _clientFirstMessageBare = [NSString stringWithFormat:@"n=%@,r=%@,g=%@", [self quote:_username], _nonce, [NSUUID UUID].UUIDString]; + return [NSString stringWithFormat:@"%@%@", _gssHeader, _clientFirstMessageBare]; +} + +-(MLScramStatus) parseServerFirstMessage:(NSString*) str +{ + MLAssert(!_finishedSuccessfully, @"SCRAM handler finished already!"); + MLAssert(!_serverFirstMessageParsed, @"SCRAM handler already parsed server-first-message!"); + NSDictionary* msg = [self parseScramString:str]; + _serverFirstMessageParsed = YES; + //server nonce MUST start with our client nonce + if(![msg[@"r"] hasPrefix:_nonce]) + return MLScramStatusNonceError; + //check for attributes not allowed per RFC + for(NSString* key in msg) + if([@"m" isEqualToString:key]) + return MLScramStatusUnsupportedMAttribute; + _serverFirstMessage = str; + _nonce = msg[@"r"]; //from now on use the full nonce + _salt = [HelperTools dataWithBase64EncodedString:msg[@"s"]]; + _iterationCount = (uint32_t)[msg[@"i"] integerValue]; + //check if SSDP downgrade protection triggered, if provided + if(msg[@"d"] != nil && _ssdpString != nil) + { + _ssdpSupported = YES; + //calculate base64 encoded SSDP hash and compare it to server sent value + NSString* ssdpHash =[HelperTools encodeBase64WithData:[self hash:[_ssdpString dataUsingEncoding:NSUTF8StringEncoding]]]; + if(![HelperTools constantTimeCompareAttackerString:msg[@"d"] withKnownString:ssdpHash]) + return MLScramStatusSSDPTriggered; + } + if(_iterationCount < 4096) + return MLScramStatusIterationCountInsecure; + return MLScramStatusServerFirstOK; +} + +//see https://stackoverflow.com/a/29299946/3528174 +-(NSString*) clientFinalMessageWithChannelBindingData:(NSData* _Nullable) channelBindingData +{ + MLAssert(!_finishedSuccessfully, @"SCRAM handler finished already!"); + MLAssert(_serverFirstMessageParsed, @"SCRAM handler did not parsed server-first-message yet!"); + //calculate gss header with optional channel binding data + NSMutableData* gssHeaderWithChannelBindingData = [NSMutableData new]; + [gssHeaderWithChannelBindingData appendData:[_gssHeader dataUsingEncoding:NSUTF8StringEncoding]]; + if(channelBindingData != nil) + [gssHeaderWithChannelBindingData appendData:channelBindingData]; + + NSData* saltedPassword = [self hashPasswordWithSalt:_salt andIterationCount:_iterationCount]; + + //calculate clientKey (e.g. HMAC(SaltedPassword, "Client Key")) + NSData* clientKey = [self hmacForKey:saltedPassword andData:[@"Client Key" dataUsingEncoding:NSUTF8StringEncoding]]; + + //calculate storedKey (e.g. H(ClientKey)) + NSData* storedKey = [self hash:clientKey]; + + //calculate authMessage (e.g. client-first-message-bare + "," + server-first-message + "," + client-final-message-without-proof) + //the x attribute is a random grease to check if servers are rfc compliant (e.g. accept optional attributes) + NSString* clientFinalMessageWithoutProof = [NSString stringWithFormat:@"c=%@,r=%@,x=%@", [HelperTools encodeBase64WithData:gssHeaderWithChannelBindingData], _nonce, [NSUUID UUID].UUIDString]; + NSString* authMessage = [NSString stringWithFormat:@"%@,%@,%@", _clientFirstMessageBare, _serverFirstMessage, clientFinalMessageWithoutProof]; + + //calculate clientSignature (e.g. HMAC(StoredKey, AuthMessage)) + NSData* clientSignature = [self hmacForKey:storedKey andData:[authMessage dataUsingEncoding:NSUTF8StringEncoding]]; + + //calculate clientProof (e.g. ClientKey XOR ClientSignature) + NSData* clientProof = [HelperTools XORData:clientKey withData:clientSignature]; + + //calculate serverKey (e.g. HMAC(SaltedPassword, "Server Key")) + NSData* serverKey = [self hmacForKey:saltedPassword andData:[@"Server Key" dataUsingEncoding:NSUTF8StringEncoding]]; + + //calculate _expectedServerSignature (e.g. HMAC(ServerKey, AuthMessage)) + _expectedServerSignature = [HelperTools encodeBase64WithData:[self hmacForKey:serverKey andData:[authMessage dataUsingEncoding:NSUTF8StringEncoding]]]; + + //return client final message + return [NSString stringWithFormat:@"%@,p=%@", clientFinalMessageWithoutProof, [HelperTools encodeBase64WithData:clientProof]]; +} + +-(MLScramStatus) parseServerFinalMessage:(NSString*) str +{ + MLAssert(!_finishedSuccessfully, @"SCRAM handler finished already!"); + MLAssert(_serverFirstMessageParsed, @"SCRAM handler did not parsed server-first-message yet!"); + NSDictionary* msg = [self parseScramString:str]; + //wrong v-value + if(![HelperTools constantTimeCompareAttackerString:msg[@"v"] withKnownString:_expectedServerSignature]) + return MLScramStatusWrongServerProof; + //server sent a SCRAM error + if(msg[@"e"] != nil) + { + DDLogError(@"SCRAM error: '%@'", msg[@"e"]); + return MLScramStatusServerError; + } + //everything was successful + _finishedSuccessfully = YES; + return MLScramStatusServerFinalOK; +} + +-(NSData*) hashPasswordWithSalt:(NSData*) salt andIterationCount:(uint32_t) iterationCount +{ + //calculate saltedPassword (e.g. Hi(Normalize(password), salt, i)) + uint32_t i = htonl(1); + NSMutableData* salti = [NSMutableData dataWithData:salt]; + [salti appendData:[NSData dataWithBytes:&i length:sizeof(i)]]; + + NSData* passwordData = [_password dataUsingEncoding:NSUTF8StringEncoding]; + NSData* saltedPasswordIntermediate = [self hmacForKey:passwordData andData:salti]; + NSData* saltedPassword = saltedPasswordIntermediate; + for(long i = 1; i < iterationCount; i++) + { + saltedPasswordIntermediate = [self hmacForKey:passwordData andData:saltedPasswordIntermediate]; + saltedPassword = [HelperTools XORData:saltedPassword withData:saltedPasswordIntermediate]; + } + return saltedPassword; +} + +-(NSString*) method +{ + if(_usingChannelBinding) + return [NSString stringWithFormat:@"%@-PLUS", _method]; + return _method; +} + + +-(NSData*) hmacForKey:(NSData*) key andData:(NSData*) data +{ + if([_method isEqualToString:@"SCRAM-SHA-1"]) + return [HelperTools sha1HmacForKey:key andData:data]; + if([_method isEqualToString:@"SCRAM-SHA-256"]) + return [HelperTools sha256HmacForKey:key andData:data]; + if([_method isEqualToString:@"SCRAM-SHA-512"]) + return [HelperTools sha512HmacForKey:key andData:data]; + NSAssert(NO, @"Unexpected error: unsupported SCRAM hash method!", (@{@"method": nilWrapper(_method)})); + return nil; +} + +-(NSData*) hash:(NSData*) data +{ + if([_method isEqualToString:@"SCRAM-SHA-1"]) + return [HelperTools sha1:data]; + if([_method isEqualToString:@"SCRAM-SHA-256"]) + return [HelperTools sha256:data]; + if([_method isEqualToString:@"SCRAM-SHA-512"]) + return [HelperTools sha512:data]; + NSAssert(NO, @"Unexpected error: unsupported SCRAM hash method!", (@{@"method": nilWrapper(_method)})); + return nil; +} + +-(NSDictionary* _Nullable) parseScramString:(NSString*) str +{ + NSMutableDictionary* retval = [NSMutableDictionary new]; + for(NSString* component in [str componentsSeparatedByString:@","]) + { + NSString* attribute = [component substringToIndex:1]; + NSString* value = [component substringFromIndex:2]; + retval[attribute] = [self unquote:value]; + } + return retval; +} + +-(NSString*) mapCharacter:(unichar) ch +{ + switch(ch) + { + //chars mapping to space (table C.1.2) + case 0x00A0: //Non-breaking space + case 0x1680: //Ogham space mark + case 0x2000: //En quad + case 0x2001: //Em quad + case 0x2002: //En space + case 0x2003: //Em space + case 0x2004: //Three-per-em space + case 0x2005: //Four-per-em space + case 0x2006: //Six-per-em space + case 0x2007: //Figure space + case 0x2008: //Punctuation space + case 0x2009: //Thin space + case 0x200A: //Hair space + case 0x202F: //Narrow no-break space + case 0x205F: //Medium mathematical space + case 0x3000: //Ideographic space + return @" "; //All mapped to regular space (U+0020) + + //chars mapping to nothing (table B.1) + case 0x0000: //NULL + case 0x0001: //Start of Heading + case 0x0002: //Start of Text + case 0x0003: //End of Text + case 0x0004: //End of Transmission + case 0x0005: //Enquiry + case 0x0006: //Acknowledge + case 0x0007: //Bell + case 0x0008: //Backspace + case 0x0009: //Horizontal Tab + case 0x000A: //Line Feed + case 0x000B: //Vertical Tab + case 0x000C: //Form Feed + case 0x000D: //Carriage Return + case 0x000E: //Shift Out + case 0x000F: //Shift In + case 0x0010: //Data Link Escape + case 0x0011: //Device Control 1 + case 0x0012: //Device Control 2 + case 0x0013: //Device Control 3 + case 0x0014: //Device Control 4 + case 0x0015: //Negative Acknowledge + case 0x0016: //Synchronous Idle + case 0x0017: //End of Transmission Block + case 0x0018: //Cancel + case 0x0019: //End of Medium + case 0x001A: //Substitute + case 0x001B: //Escape + case 0x001C: //File Separator + case 0x001D: //Group Separator + case 0x001E: //Record Separator + case 0x001F: //Unit Separator + case 0x007F: //DELETE (DEL) + //Non-character code points (U+FDD0 to U+FDEF, reserved for internal use) + case 0xFDD0: + case 0xFDD1: + case 0xFDD2: + case 0xFDD3: + case 0xFDD4: + case 0xFDD5: + case 0xFDD6: + case 0xFDD7: + case 0xFDD8: + case 0xFDD9: + case 0xFDDA: + case 0xFDDB: + case 0xFDDC: + case 0xFDDD: + case 0xFDDE: + case 0xFDDF: + case 0xFEFF: //Zero Width No-Break Space (ZWNBS) + return @""; //These characters are mapped to nothing (removed from the string) + + default: + return [NSString stringWithCharacters:&ch length:1]; //No mapping, return the character as is + } +} + +-(NSString*) SASLPrep:(NSString*) str isQuery:(BOOL) isQuery +{ + //saslprep/stringprep step 1: map characters + NSMutableString* mappedString = [NSMutableString stringWithCapacity:str.length]; + for(NSUInteger i=0; i_clientFirstMessageBare = @"n=user,r=12C4CD5C-E38E-4A98-8F6D-15C38F51CCC6"; + s->_gssHeader = @"p=tls-exporter,,"; + + s->_serverFirstMessage = @"r=12C4CD5C-E38E-4A98-8F6D-15C38F51CCC6a09117a6-ac50-4f2f-93f1-93799c2bddf6,s=QSXCR+Q6sek8bf92,i=4096,d=dRc3RenuSY9ypgPpERowoaySQZY="; + s->_nonce = @"12C4CD5C-E38E-4A98-8F6D-15C38F51CCC6a09117a6-ac50-4f2f-93f1-93799c2bddf6"; + s->_salt = [HelperTools dataWithBase64EncodedString:@"QSXCR+Q6sek8bf92"]; + s->_iterationCount = 4096; + + NSString* client_final_msg = [s clientFinalMessageWithChannelBindingData:[@"THIS IS FAKE CB DATA" dataUsingEncoding:NSUTF8StringEncoding]]; + DDLogError(@"client_final_msg: %@", client_final_msg); + DDLogError(@"_expectedServerSignature: %@", s->_expectedServerSignature); + + [HelperTools flushLogsWithTimeout:0.250]; + exit(0); +} + +@end diff --git a/Monal/Classes/ServerDetails.swift b/Monal/Classes/ServerDetails.swift new file mode 100644 index 0000000..614ef8c --- /dev/null +++ b/Monal/Classes/ServerDetails.swift @@ -0,0 +1,461 @@ +// +// ServerDetails.swift +// Monal +// +// Created by lissine on 3/9/2024. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +private enum Status { + case success, normal, warning, error +} + +private class EntryData: Identifiable, ObservableObject { + let id = UUID() + let title: String + let description: String + let status: Status + + init(title: String, description: String, status: Status) { + self.title = title + self.description = description + self.status = status + } + + var color: Color { + switch status { + case .success: + return Color(.serverDetailsEntrySuccess) + case .normal: + return .clear + case .warning: + return Color(.serverDetailsEntryWarning) + case .error: + return Color(.serverDetailsEntryError) + } + } +} + +private struct ServerDetailsEntry: View { + @ObservedObject var entryData: EntryData + + init(_ entryData: EntryData) { + self.entryData = entryData + } + + var body: some View { + VStack(alignment: .leading) { + Text(LocalizedStringKey(entryData.title)) + .font(.headline) + Text(LocalizedStringKey(entryData.description)) + .font(.caption) + } + .listRowBackground(entryData.color) + } +} + +struct ServerDetails: View { + let xmppAccount: xmpp + + private func showServerVersionInfoView(connection: MLXMPPConnection) -> some View { + let serverVersion = connection.serverVersion + let serverName = serverVersion?.appName ?? NSLocalizedString("", comment: "server details") + let serverVersionString = serverVersion?.appVersion ?? NSLocalizedString("", comment: "server details") + let serverPlatform = serverVersion?.platformOs != nil ? String(format: NSLocalizedString(" running on %@", comment: "server details"), serverVersion!.platformOs!) : "" + let description = String(format: NSLocalizedString("version %@%@", comment: "server details"), serverVersionString, serverPlatform) + let linkText = NSLocalizedString("Considerations for Server Administrators", comment: "server details") + let link = "[\(linkText)](https://github.com/monal-im/Monal/wiki/Considerations-for-XMPP-server-admins)" + return ServerDetailsEntry( + EntryData( + title: serverName, + description: "\(description)\n\n\(link)", + status: .normal + ) + ) + } + + private func getXEPEntryData(connection: MLXMPPConnection) -> [EntryData] { + let maxFileUploadSize = HelperTools.bytes(toHuman: Int64(connection.uploadSize)) + let result: [EntryData] = [ + EntryData( + title: NSLocalizedString("XEP-0163 Personal Eventing Protocol", comment: ""), + description: NSLocalizedString("This specification defines semantics for using the XMPP publish-subscribe protocol to broadcast state change events associated with an instant messaging and presence account.", comment: ""), + status: connection.supportsPubSub ? (connection.supportsModernPubSub ? .success : .warning) : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0191: Blocking Command", comment: ""), + description: NSLocalizedString("XMPP protocol extension for communications blocking.", comment: ""), + status: connection.serverDiscoFeatures.contains("urn:xmpp:blocking") ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0198: Stream Management", comment: ""), + description: NSLocalizedString("Resume a stream when disconnected. Results in faster reconnect and saves battery life.", comment: ""), + status: connection.supportsSM3 ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0199: XMPP Ping", comment: ""), + description: NSLocalizedString("XMPP protocol extension for sending application-level pings over XML streams.", comment: ""), + status: connection.serverDiscoFeatures.contains("urn:xmpp:ping") ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0215: External Service Discovery", comment: ""), + description: NSLocalizedString("XMPP protocol extension for discovering services external to the XMPP network, like STUN or TURN servers needed for A/V calls.", comment: ""), + status: connection.serverDiscoFeatures.contains("urn:xmpp:extdisco:2") ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0237: Roster Versioning", comment: ""), + description: NSLocalizedString("Defines a proposed modification to the XMPP roster protocol that enables versioning of rosters such that the server will not send the roster to the client if the roster has not been modified.", comment: ""), + status: connection.supportsRosterVersioning ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0280: Message Carbons", comment: ""), + description: NSLocalizedString("Synchronize your messages on all loggedin devices.", comment: ""), + status: connection.usingCarbons2 ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0313: Message Archive Management", comment: ""), + description: NSLocalizedString("Access message archives on the server.", comment: ""), + status: connection.accountDiscoFeatures.contains("urn:xmpp:mam:2") ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0352: Client State Indication", comment: ""), + description: NSLocalizedString("Indicate when a particular device is active or inactive. Saves battery.", comment: ""), + status: connection.supportsClientState ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0357: Push Notifications", comment: ""), + description: NSLocalizedString("Receive push notifications via Apple even when disconnected. Vastly improves reliability.", comment: ""), + status: connection.accountDiscoFeatures.contains("urn:xmpp:push:0") ? (connection.pushEnabled ? .success : .warning) : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0363: HTTP File Upload", comment: ""), + description: String(format: NSLocalizedString("Upload files to the server to share with others. (Maximum allowed size of files reported by the server: %@)", comment: ""), maxFileUploadSize), + status: connection.supportsHTTPUpload ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0379: Pre-Authenticated Roster Subscription", comment: ""), + description: NSLocalizedString("Defines a protocol and URI scheme for pre-authenticated roster links that allow a third party to automatically obtain the user's presence subscription.", comment: ""), + status: connection.supportsRosterPreApproval ? .success : .error + ), + + EntryData( + title: NSLocalizedString("XEP-0474: SASL SCRAM Downgrade Protection", comment: ""), + description: NSLocalizedString("This specification provides a way to secure the SASL and SASL2 handshakes against method and channel-binding downgrades.", comment: ""), + status: connection.supportsSSDP ? .success : .error + ), + ] + return result + } + + private func getServerContactAddressesEntryData(connection: MLXMPPConnection) -> [EntryData] { + let contactAddresses = connection.serverContactAddresses as! [String: [String]] + guard contactAddresses.count > 0 else { + return [ + EntryData( + title: NSLocalizedString("None", comment: ""), + description: NSLocalizedString("This server does not provide any contact addresses.", comment: ""), + status: .normal + ) + ] + } + + var result: [EntryData] = [] + for (addressType, addresses) in contactAddresses.sortedByKey() { + var title: String + // We need to hardcode the strings so they can be localized, at least until the string extraction script is fixed. + switch(addressType) { + case "abuse-addresses": + title = NSLocalizedString("Abuse", comment: "") + case "admin-addresses": + title = NSLocalizedString("Admin", comment: "") + case "feedback-addresses": + title = NSLocalizedString("Feedback", comment: "") + case "sales-addresses": + title = NSLocalizedString("Sales", comment: "") + case "security-addresses": + title = NSLocalizedString("Security", comment: "") + case "status-addresses": + title = NSLocalizedString("Status", comment: "") + case "support-addresses": + title = NSLocalizedString("Support", comment: "") + default: + title = addressType.replacingOccurrences(of: "-", with: " ") + } + result.append( + EntryData( + title: "\(title):", + description: addresses.map{"[\($0)](\($0))"}.joined(separator: "\n\n"), + status: .normal + ) + ) + } + return result + } + private func getMUCEntryData(connection: MLXMPPConnection) -> [EntryData] { + let conferenceServers = connection.conferenceServerIdentities as! [[String: String]] + guard conferenceServers.count > 0 else { + return [ + EntryData( + title: NSLocalizedString("None", comment: ""), + description: NSLocalizedString("This server does not provide any MUC servers.", comment: ""), + status: .error + ) + ] + } + + var result: [EntryData] = [] + for entry in conferenceServers { + result.append( + EntryData( + title: String(format: NSLocalizedString("Server: %@", comment: ""), entry["jid"] ?? "error"), + description: String(format: NSLocalizedString("%@ (type '%@', category '%@')", comment: ""), entry["name"]!, entry["type"]!, entry["category"]!), + status: entry["type"] == "text" ? .success : .normal + ) + ) + } + return result + } + + private func getStunTurnEntryData(connection: MLXMPPConnection) -> [EntryData] { + var result: [EntryData] = [] + + let stunTurnServers = connection.discoveredStunTurnServers as! [[String: String]] + for service in stunTurnServers { + var status = Status.normal + switch(service["type"]) { + case "stun", "turn", "stuns", "turns": + status = .success + default: + status = .error + } + result.append( + EntryData( + title: service["type"] ?? "error", + description: "\(service["host"]!):\(service["port"]!)", + status: status + ) + ) + } + + if result.isEmpty { + result.append( + EntryData( + title: NSLocalizedString("None", comment: ""), + description: NSLocalizedString("This server does not provide any STUN / TURN services.", comment: ""), + status: .error + ) + ) + } + + return result + } + + private func getSRVEntryData(xmppAccount: xmpp) -> [EntryData] { + guard xmppAccount.discoveredServersList.count > 0 else { + return [ + EntryData( + title: NSLocalizedString("None", comment: ""), + description: NSLocalizedString("This server does not have any SRV records in DNS.", comment: ""), + status: .error + ) + ] + } + + var result: [EntryData] = [] + var foundCurrentConn: Bool = false + for srvEntry in (xmppAccount.discoveredServersList as! [[String: Any]]) { + let hostname = srvEntry["server"] as! String + let port = srvEntry["port"] as! NSNumber + let isSecure = srvEntry["isSecure"] as! Bool + let prio = srvEntry["priority"] as! NSNumber + + var entryStatus = Status.normal + + // 'connectServer()' has been renamed to 'connect()' + if (xmppAccount.connectionProperties.server.connect() == hostname && + xmppAccount.connectionProperties.server.connectPort() == port && + xmppAccount.connectionProperties.server.isDirectTLS() == isSecure + ) { + entryStatus = .success + foundCurrentConn = true + } else if !foundCurrentConn { + // Set the status of all connections entries that failed to error + // discoveredServersList is sorted. Therfore all entries before foundCurrentConn == true have failed + entryStatus = .error + } + result.append( + EntryData( + title: String(format: NSLocalizedString("Server: %@", comment: ""), hostname), + description: String(format: NSLocalizedString("Port: %@, Direct TLS: %@, Priority: %@", comment: ""), port, (isSecure ? NSLocalizedString("Yes", comment: "") : NSLocalizedString("No", comment: "")), prio), + status: entryStatus + ) + ) + + } + return result + } + + private func getTLSEntryData(connection: MLXMPPConnection) -> [EntryData] { + return [ + EntryData( + title: NSLocalizedString("TLS 1.2", comment: ""), + description: NSLocalizedString("Older, slower, but still secure TLS version", comment: ""), + status: connection.tlsVersion == "1.2" ? .success : .normal + ), + EntryData( + title: NSLocalizedString("TLS 1.3", comment: ""), + description: NSLocalizedString("Newest TLS version which is faster than TLS 1.2", comment: ""), + status: connection.tlsVersion == "1.3" ? .success : .normal + ), + ] + } + + private func getSASLEntryData(connection: MLXMPPConnection) -> [EntryData] { + guard connection.saslMethods.count > 0 else { + return [ + EntryData( + title: NSLocalizedString("None", comment: ""), + description: NSLocalizedString("This server does not support modern SASL2 authentication.", comment: ""), + status: .error + ) + ] + } + + var result: [EntryData] = [] + let saslMethods = connection.saslMethods as! [String: Bool] + for (method, used) in saslMethods.sortedByKey() { + let supported = (SCRAM.supportedMechanisms(includingChannelBinding: true) as! [String]).contains(method) + var description: String + switch method { + case "PLAIN": + description = NSLocalizedString("Sends password in cleartext (only encrypted by TLS), not very secure", comment: "") + case "EXTERNAL": + description = NSLocalizedString("Uses TLS client certificates for authentication", comment: "") + case let method where (method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS")): + description = NSLocalizedString("Salted Challenge Response Authentication Mechanism using the given Hash Method additionally secured by Channel-Binding", comment: "") + case let method where method.hasPrefix("SCRAM-"): + description = NSLocalizedString("Salted Challenge Response Authentication Mechanism using the given Hash Method", comment: "") + default: + description = NSLocalizedString("Unknown authentication method", comment: "") + } + result.append( + EntryData( + title: String(format: NSLocalizedString("Method: %@", comment: ""), method), + description: description, + status: used ? .success : (!supported ? .warning : .normal) + ) + ) + } + return result + } + + private func getChannelBindingEntryData(xmppAccount: xmpp, connection: MLXMPPConnection) -> [EntryData] { + guard connection.channelBindingTypes.count > 0 else { + return [ + EntryData( + title: NSLocalizedString("None", comment: ""), + description: NSLocalizedString("This server does not support any modern channel-binding to secure against MITM attacks on the TLS layer.", comment: ""), + status: .error + ) + ] + } + + var result: [EntryData] = [] + let channelBindingTypes = connection.channelBindingTypes as! [String: Bool] + let supportedChannelBindingTypes = xmppAccount.supportedChannelBindingTypes as! [String] + for (type, used) in channelBindingTypes.sortedByKey() { + let supported = supportedChannelBindingTypes.contains(type) + var description: String + + switch type { + case "tls-exporter": + description = NSLocalizedString("Secure channel-binding defined for TLS1.3 and some TLS1.2 connections.", comment: "") + case "tls-server-end-point": + description = NSLocalizedString("Weakest channel-binding type, not securing against stolen certs/keys, but detects wrongly issued certs.", comment: "") + default: + description = NSLocalizedString("Unknown channel-binding type", comment: "") + } + result.append( + EntryData( + title: String(format: NSLocalizedString("Type: %@", comment: ""), type), + description: description, + status: used ? .success : (!supported ? .warning : .normal) + ) + ) + + } + return result + } + + var body: some View { + let connection = xmppAccount.connectionProperties + + List { + Section(header: Text("This is the software running on your server.")) { + showServerVersionInfoView(connection: connection) + } + + Section(header: Text("These are your server's contact addresses.")) { + ForEach(getServerContactAddressesEntryData(connection: connection)) { entryData in + ServerDetailsEntry(entryData) + } + } + + Section(header: Text("These are the modern XMPP capabilities Monal detected on your server after you have logged in.")) { + ForEach(getXEPEntryData(connection: connection)) { entryData in + ServerDetailsEntry(entryData) + } + } + + Section(header: Text("These are the MUC servers detected by Monal (blue entry used by Monal).")) { + ForEach(getMUCEntryData(connection: connection)) { entryData in + ServerDetailsEntry(entryData) + } + } + + Section(header: Text("These are STUN and TURN services announced by your server (blue entries are used by Monal).")) { + ForEach(getStunTurnEntryData(connection: connection)) { entryData in + ServerDetailsEntry(entryData) + } + } + + Section(header: Text("These are SRV resource records found for your domain.")) { + ForEach(getSRVEntryData(xmppAccount: xmppAccount)) { entryData in + ServerDetailsEntry(entryData) + } + } + + Section(header: Text("These are the TLS versions supported by Monal, the one used to connect to your server will be green.")) { + ForEach(getTLSEntryData(connection: connection)) { entryData in + ServerDetailsEntry(entryData) + } + } + + Section(header: Text("These are the SASL2 methods your server supports (used one in blue, orange ones unsupported by Monal).")) { + ForEach(getSASLEntryData(connection: connection)) { entryData in + ServerDetailsEntry(entryData) + } + } + + Section(header: Text("These are the channel-binding types your server supports to detect attacks on the TLS layer (used one in blue, orange ones unsupported by Monal).")) { + ForEach(getChannelBindingEntryData(xmppAccount: xmppAccount, connection: connection)) { entryData in + ServerDetailsEntry(entryData) + } + } + + } + .navigationTitle(connection.identity.domain) + .listStyle(.grouped) + } +} diff --git a/Monal/Classes/SwiftHelpers.swift b/Monal/Classes/SwiftHelpers.swift new file mode 100644 index 0000000..eb5bd0f --- /dev/null +++ b/Monal/Classes/SwiftHelpers.swift @@ -0,0 +1,500 @@ +// +// SwiftHelpers.swift +// monalxmpp +// +// Created by Thilo Molitor on 16.08.23. +// Copyright © 2023 monal-im.org. All rights reserved. +// + +//see https://davedelong.com/blog/2018/01/19/simplifying-swift-framework-development/ for explanation of @_exported +@_exported import Foundation +@_exported import CocoaLumberjackSwift +@_exported import Logging +@_exported import PromiseKit +import CocoaLumberjackSwiftLogBackend +import LibMonalRustSwiftBridge +import Combine +//needed to render SVG to UIImage +import SwiftUI +import SVGView + +//import some defines in MLConstants.h into swift +let kAppGroup = HelperTools.getObjcDefinedValue(.kAppGroup) +let kMonalOpenURL = HelperTools.getObjcDefinedValue(.kMonalOpenURL) +let kBackgroundProcessingTask = HelperTools.getObjcDefinedValue(.kBackgroundProcessingTask) +let kBackgroundRefreshingTask = HelperTools.getObjcDefinedValue(.kBackgroundRefreshingTask) +let kMonalKeychainName = HelperTools.getObjcDefinedValue(.kMonalKeychainName) +let kMucTypeGroup = HelperTools.getObjcDefinedValue(.kMucTypeGroup) +let kMucTypeChannel = HelperTools.getObjcDefinedValue(.kMucTypeChannel) + +let kMucRoleModerator = HelperTools.getObjcDefinedValue(.kMucRoleModerator) +let kMucRoleNone = HelperTools.getObjcDefinedValue(.kMucRoleNone) +let kMucRoleParticipant = HelperTools.getObjcDefinedValue(.kMucRoleParticipant) +let kMucRoleVisitor = HelperTools.getObjcDefinedValue(.kMucRoleVisitor) + +let kMucAffiliationOwner = HelperTools.getObjcDefinedValue(.kMucAffiliationOwner) +let kMucAffiliationAdmin = HelperTools.getObjcDefinedValue(.kMucAffiliationAdmin) +let kMucAffiliationMember = HelperTools.getObjcDefinedValue(.kMucAffiliationMember) +let kMucAffiliationOutcast = HelperTools.getObjcDefinedValue(.kMucAffiliationOutcast) +let kMucAffiliationNone = HelperTools.getObjcDefinedValue(.kMucAffiliationNone) +let kMucActionShowProfile = HelperTools.getObjcDefinedValue(.kMucActionShowProfile) +let kMucActionReinvite = HelperTools.getObjcDefinedValue(.kMucActionReinvite) + +let SHORT_PING = HelperTools.getObjcDefinedValue(.SHORT_PING) +let LONG_PING = HelperTools.getObjcDefinedValue(.LONG_PING) +let MUC_PING = HelperTools.getObjcDefinedValue(.MUC_PING) +let BGFETCH_DEFAULT_INTERVAL = HelperTools.getObjcDefinedValue(.BGFETCH_DEFAULT_INTERVAL) + +public typealias monal_timer_block_t = @convention(block) (MLDelayableTimer?) -> Void; +public typealias monal_void_block_t = @convention(block) () -> Void; +public typealias monal_id_block_t = @convention(block) (AnyObject?) -> Void; +public typealias monal_id_returning_void_block_t = @convention(block) () -> AnyObject?; +public typealias monal_id_returning_id_block_t = @convention(block) (AnyObject?) -> AnyObject?; + +extension MLContact : Identifiable {} //make MLContact be usable in swiftui ForEach clauses etc. +extension Quicksy_Country : Identifiable {} //make Quicksy_Country be usable in swiftui ForEach clauses etc. + +//see https://stackoverflow.com/a/40629365/3528174 +extension String: Error {} + +//see https://stackoverflow.com/a/40592109/3528174 +public func objcCast(_ obj: Any) -> T { + return unsafeBitCast(obj as AnyObject, to:T.self) +} + +public func unreachable(_ text: String = "unreachable", _ auxData: [String:AnyObject] = [String:AnyObject](), file: String = #file, line: Int = #line, function: String = #function) -> Never { + DDLogError("unreachable: \(file) \(line) \(function)") + HelperTools.mlAssert(withText:text, andUserData:auxData, andFile:(file as NSString).utf8String!, andLine:Int32(line), andFunc:(function as NSString).utf8String!) + while true {} //should never be reached +} + +public func MLAssert(_ predicate: @autoclosure() -> Bool, _ text: String = "", _ auxData: [String:AnyObject] = [String:AnyObject](), file: String = #file, line: Int = #line, function: String = #function) { + if !predicate() { + HelperTools.mlAssert(withText:text, andUserData:auxData, andFile:(file as NSString).utf8String!, andLine:Int32(line), andFunc:(function as NSString).utf8String!) + while true {} //should never be reached + } +} + +public func nilWrapper(_ value: Any?) -> Any { + if let value = value { + return value + } else { + return NSNull() + } +} + +public func nilExtractor(_ value: Any?) -> Any? { + if value is NSNull { + return nil + } else { + return value + } +} + +@objc public enum NotificationPrivacySettingOption: Int, CaseIterable, RawRepresentable { + case DisplayNameAndMessage + case DisplayOnlyName + case DisplayOnlyPlaceholder +} + +class KVOObserver: NSObject { + var obj: NSObject + var keyPath: String + var objectWillChange: ()->Void + + init(obj:NSObject, keyPath:String, objectWillChange: @escaping ()->Void) { + self.obj = obj + self.keyPath = keyPath + self.objectWillChange = objectWillChange + super.init() + self.obj.addObserver(self, forKeyPath: keyPath, options: [], context: nil) + } + + deinit { + self.obj.removeObserver(self, forKeyPath:self.keyPath) + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + //DDLogVerbose("\(String(describing:object)): keyPath \(String(describing:keyPath)) changed: \(String(describing:change))") + self.objectWillChange() + } +} + +@dynamicMemberLookup +public class ObservableKVOWrapper: ObservableObject, Hashable, Equatable, CustomStringConvertible, Identifiable { + public var obj: ObjType + private var observedMembers: NSMutableSet = NSMutableSet() + private var observers: [KVOObserver] = Array() + + public init(_ obj: ObjType) { + self.obj = obj + } + + private func addObserverForMember(_ member: String){ + if(!self.observedMembers.contains(member)) { + DDLogDebug("Adding observer for member '\(member)'...") + self.observers.append(KVOObserver(obj:self.obj, keyPath:member, objectWillChange: { [weak self] in + guard let self = self else { + return + } + //DDLogDebug("Observer said '\(member)' has changed...") + DispatchQueue.main.async { + DDLogDebug("Calling self.objectWillChange.send() for '\(member)'...") + self.objectWillChange.send() + } + })) + self.observedMembers.add(member) + } + } + + private func getWrapper(for member:String) -> AnyObject? { + addObserverForMember(member) + //DDLogDebug("Returning value for dynamicMember \(member): \(String(describing:self.obj.value(forKey:member)))") + return self.obj.value(forKey:member) as AnyObject? + } + + private func setWrapper(for member:String, value:AnyObject?) { + self.obj.setValue(value, forKey:member) + } + + public subscript(member: String) -> T { + get { + if let value = self.getWrapper(for:member) as? T { + return value + } else { + HelperTools.throwException(withName:"ObservableKVOWrapperCastingError", reason:"Could not cast member '\(String(describing:member))' to expected type \(String(describing:T.self))", userInfo:[ + "key": "\(String(describing:member))", + "type": "\(String(describing:T.self))", + ]) + } + } + set { + self.setWrapper(for:member, value:newValue as AnyObject?) + } + } + + public subscript(dynamicMember member: String) -> T { + get { + if let value = self.getWrapper(for:member) as? T { + return value + } else { + HelperTools.throwException(withName:"ObservableKVOWrapperCastingError", reason:"Could not cast dynamicMember '\(String(describing:member))' to expected type \(String(describing:T.self))", userInfo:[ + "key": "\(String(describing:member))", + "type": "\(String(describing:T.self))", + ]) + } + } + set { + self.setWrapper(for:member, value:newValue as AnyObject?) + } + } + + public var description: String { + return "ObservableKVOWrapper<\(String(describing:self.obj))>" + } + + @inlinable + public static func ==(lhs: ObservableKVOWrapper, rhs: ObservableKVOWrapper) -> Bool { + return lhs.obj.isEqual(rhs.obj) + } + + @inlinable + public static func ==(lhs: ObservableKVOWrapper, rhs: ObjType) -> Bool { + return lhs.obj.isEqual(rhs) + } + + @inlinable + public static func ==(lhs: ObjType, rhs: ObservableKVOWrapper) -> Bool { + return lhs.isEqual(rhs.obj) + } + + // see https://stackoverflow.com/a/33320737 + @inlinable + public static func ===(lhs: ObservableKVOWrapper, rhs: ObservableKVOWrapper) -> Bool { + return lhs.obj === rhs.obj + } + + @inlinable + public static func ===(lhs: ObservableKVOWrapper, rhs: ObjType) -> Bool { + return lhs.obj === rhs + } + + @inlinable + public static func ===(lhs: ObjType, rhs: ObservableKVOWrapper) -> Bool { + return lhs === rhs.obj + } + + @inlinable + public func hash(into hasher: inout Hasher) { + hasher.combine(self.obj.hashValue) + } +} + +struct RuntimeError: LocalizedError { + let description: String + + init(_ description: String) { + self.description = description + } + + var errorDescription: String? { + description + } +} + +extension AnyPromise { + public func toGuarantee() -> Guarantee { + return Guarantee { seal in + self.done { value in + if let value = nilExtractor(value) as? T { + seal(value) + } else { + HelperTools.throwException(withName:"AnyPromiseToGuaranteeConversionError", reason:"Could not cast value to type \(String(describing: T.self))", userInfo:[ + "type": "\(String(describing: T.self))", + "value": "\(String(describing:value))", + "from_anyPromise": "\(String(describing: self))", + ]) + } + }.catch { error in + HelperTools.throwException(withName:"AnyPromiseToGuaranteeConversionError", reason:"Uncatched promise error: \(error)", userInfo:[ + "error": "\(String(describing:error))", + "promise": "\(String(describing: self))", + ]) + } + } + } + + public func toPromise() -> Promise { + return Promise { seal in + self.done { value in + if let value = nilExtractor(value) as? T { + seal.fulfill(value) + } else { + seal.reject(PMKError.invalidCallingConvention) + } + }.catch { error in + seal.reject(error) + } + } + } +} + +//since we can not be generic over actors, any new actor we create has to be added here, if we want to use it in conjunction with promises +//see https://forums.swift.org/t/generic-over-global-actor/67304/2 +public extension Promise { + @MainActor + func asyncOnMainActor() async throws -> T { + try await withCheckedThrowingContinuation { continuation in + done { value in + continuation.resume(returning: value) + }.catch(policy: .allErrors) { error in + continuation.resume(throwing: error) + } + } + } +} +public extension Guarantee { + @MainActor + func asyncOnMainActor() async -> T { + await withCheckedContinuation { continuation in + done { value in + continuation.resume(returning: value) + } + } + } +} + +//see https://www.avanderlee.com/swift/property-wrappers/ +//and https://fatbobman.com/en/posts/adding-published-ability-to-custom-property-wrapper-types/ +@propertyWrapper +public struct defaultsDB { + private let key: String + private var container: UserDefaults = HelperTools.defaultsDB() + + public init(_ key: String) { + self.key = key + } + + public var wrappedValue: Value { + get { + if let value = container.object(forKey: key) as? Value { + return value + } else { + HelperTools.throwException(withName:"DefaultsDBCastingError", reason:"Could not cast deaultsDB entry '\(String(describing:key))' to expected type \(String(describing: Value.self))", userInfo:[ + "key": "\(String(describing:key))", + "type": "\(String(describing: Value.self))", + ]) + } + } + set { + if let optional = newValue as? OptionalProtocol { + if optional.isSome() { + container.set(newValue, forKey: key) + } else { + container.removeObject(forKey:key) + } + } else { + container.set(newValue, forKey: key) + } + container.synchronize() + } + } + + public static subscript( + _enclosingInstance observed: OuterSelf, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> Value { + get { observed[keyPath: storageKeyPath].wrappedValue } + set { + if let subject = observed.objectWillChange as? ObservableObjectPublisher { + subject.send() // Before modifying wrappedValue + observed[keyPath: storageKeyPath].wrappedValue = newValue + } else { + observed[keyPath: storageKeyPath].wrappedValue = newValue + } + } + } +} + +//see https://stackoverflow.com/a/32780793 +protocol OptionalProtocol { + func isSome() -> Bool + func unwrap() -> Any +} +extension Optional : OptionalProtocol { + func isSome() -> Bool { + switch self { + case .none: return false + case .some: return true + } + } + + func unwrap() -> Any { + switch self { + // If a nil is unwrapped it will crash! + case .none: unreachable("nil unwrap!") + case .some(let unwrapped): return unwrapped + } + } +} + +@objcMembers +public class SwiftHelpers: NSObject { + public static func initSwiftHelpers() { + // Use CocoaLumberjack as swift-log backend + LoggingSystem.bootstrapWithCocoaLumberjack(for: DDLog.sharedInstance, defaultLogLevel:Logger.Level.debug) + // Set rust panic handler to this closure + setRustPanicHandler({(text: String, backtrace: String) in + HelperTools.handleRustPanic(withText: text, andBacktrace:backtrace) + }); + } + + //we use the main actor here, because ImageRenderer needs to run in the main actor + //(and we don't want to overcomplicate things here by using a Task and returning a Promise) + @MainActor + private static func _renderSVG(_ svgView: T) -> UIImage? { + var image: UIImage? = nil + if HelperTools.isAppExtension() { + image = ImageRenderer(content:svgView.scaledToFit().frame(width: 320, height: 200)).uiImage + DDLogDebug("We are in appex: mirroring SVG image on Y axis..."); + image = HelperTools.mirrorImage(onXAxis:image) + } else { + image = ImageRenderer(content:svgView.scaledToFit().frame(width: 1280, height: 960)).uiImage + } + return image + } + + //this is wrapped by HelperTools.renderUIImage(fromSVGURL) / [HelperTools renderUIImageFromSVGURL:] + //because MLChatImageCell wasn't able to import the monalxmpp-Swift bridging header somehow (but importing HelperTools works just fine) + @objc(_renderUIImageFromSVGURL:) + public static func _renderUIImageFromSVG(url: URL?) -> AnyPromise { + return AnyPromise(Promise { seal in + guard let url = url, let svgView = SVGParser.parse(contentsOf: url)?.toSwiftUI() else { + return seal.fulfill(nil) + } + Task { + return seal.fulfill(await self._renderSVG(svgView)) + } + }) + } + + //this is wrapped by HelperTools.renderUIImage(fromSVGURL) / [HelperTools renderUIImageFromSVGURL:] + //because MLChatImageCell wasn't able to import the monalxmpp-Swift bridging header somehow (but importing HelperTools works just fine) + @objc(_renderUIImageFromSVGData:) + public static func _renderUIImageFromSVG(data: Data?) -> AnyPromise { + return AnyPromise(Promise { seal in + guard let data = data, let svgView = SVGParser.parse(data: data)?.toSwiftUI() else { + return seal.fulfill(nil) + } + Task { + return seal.fulfill(await self._renderSVG(svgView)) + } + }) + } +} + +//TODO: remove this +extension UIImage { + public func thumbnail(size: CGSize) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + defer { UIGraphicsEndImageContext() } + draw(in: CGRect(origin: .zero, size: size)) + return UIGraphicsGetImageFromCurrentImageContext() + } +} + +// ********************************************** +// **************** rust bridges **************** +// ********************************************** + +fileprivate extension RustVec { + func intoArray() -> [T] { + var array: [T] = [] + for _ in 0.. String? { + if let retval = sdp_str_to_jingle_str(sdp, initiator) { + //trigger_panic() + //interesting: https://gist.github.com/martinmroz/5905c65e129d22a1b56d84f08b35a0f4 to extract rust string + //see https://www.reddit.com/r/rust/comments/rqr0aj/swiftbridge_generate_ffi_bindings_between_rust/hqdud0b + return retval.toString() + } + DDLogDebug("Got empty optional from rust!") + return nil + } + + @objc(getSDPStringForJingleString:withInitiator:) + public static func getSDPStringForJingleString(_ jingle: String, with initiator:Bool) -> String? { + if let retval = jingle_str_to_sdp_str(jingle, initiator) { + //interesting: https://gist.github.com/martinmroz/5905c65e129d22a1b56d84f08b35a0f4 to extract rust string + //see https://www.reddit.com/r/rust/comments/rqr0aj/swiftbridge_generate_ffi_bindings_between_rust/hqdud0b + return retval.toString() + } + DDLogDebug("Got empty optional from rust!") + return nil + } +} + +@objcMembers +public class HtmlParserBridge : NSObject { + var document: MonalHtmlParser + + public init(html: String) { + self.document = MonalHtmlParser(html) + } + + public func select(_ selector: String, attribute: String? = nil) throws -> [String] { + return self.document.select(selector, attribute).intoArray().map { $0.toString() } + } +} diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift new file mode 100644 index 0000000..34d94e2 --- /dev/null +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -0,0 +1,838 @@ +// +// ContactDetailsInterface.swift +// Monal +// +// Created by Jan on 22.10.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +//see https://davedelong.com/blog/2018/01/19/simplifying-swift-framework-development/ for explanation of @_exported +@_exported import Foundation +@_exported import CocoaLumberjack +//@_exported import CocoaLumberjackSwift +@_exported import Logging +@_exported import SwiftUI +@_exported import monalxmpp +@_exported import Combine +import PhotosUI +import FLAnimatedImage +import OrderedCollections +import CropViewController + +//see https://stackoverflow.com/a/62207329/3528174 +//and https://www.hackingwithswift.com/forums/100-days-of-swiftui/extending-shapestyle-for-adding-colors-instead-of-extending-color/12324 +public extension ShapeStyle where Self == Color { + static var interpolatedWindowBackground: Color { Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor.systemBackground : UIColor.secondarySystemBackground }) } + static var background: Color { Color(UIColor.systemBackground) } + static var secondaryBackground: Color { Color(UIColor.secondarySystemBackground) } + static var tertiaryBackground: Color { Color(UIColor.tertiarySystemBackground) } +} + +extension Binding { + func optionalMappedToBool() -> Binding where Value == Wrapped? { + Binding( + get: { self.wrappedValue != nil }, + set: { newValue in + MLAssert(!newValue, "New value should never be true when writing to a binding created by optionalMappedToBool()") + self.wrappedValue = nil + } + ) + } +} +extension Binding { + func bytecount(mappedTo: Double) -> Binding where Value == UInt { + Binding( + get: { Double(self.wrappedValue) / mappedTo }, + set: { newValue in self.wrappedValue = UInt(newValue * mappedTo) } + ) + } +} + +class SheetDismisserProtocol: ObservableObject { + weak var host: UIHostingController? = nil + func dismiss() { + host?.dismiss(animated: true) + } + func dismissWithoutAnimation() { + host?.dismiss(animated: false) + } + func replace(with view: V) where V: View { + host?.rootView = AnyView(view) + } +} + +func getContactList(viewContact: (ObservableKVOWrapper?)) -> OrderedSet> { + if let contact = viewContact { + if(contact.isMuc) { + //this uses the account the muc belongs to and treats every other account to be remote, + //even when multiple accounts of the same monal instance are in the same group + var contactList : OrderedSet> = OrderedSet() + for memberInfo in Array(DataLayer.sharedInstance().getMembersAndParticipants(ofMuc: contact.contactJid, forAccountID: contact.accountID)) { + //jid can be participant_jid (if currently joined to muc) or member_jid (if not joined but member of muc) + guard let jid = memberInfo["participant_jid"] as? String ?? memberInfo["member_jid"] as? String else { + continue + } + contactList.append(ObservableKVOWrapper(MLContact.createContact(fromJid: jid, andAccountID: contact.accountID))) + } + return contactList + } else { + return [contact] + } + } else { + return [] + } +} + +func promisifyMucAction(account: xmpp, mucJid: String, action: @escaping () throws -> Void) -> Promise { + return Promise { seal in + DispatchQueue.global(qos: .background).async { + account.mucProcessor.addUIHandler({_data in let data = _data as! NSDictionary + let success : Bool = data["success"] as! Bool; + if !success { + seal.reject(data["errorMessage"] as? String ?? "Unknown error!") + } else { + if let callback = data["callback"] { + seal.fulfill(objcCast(callback) as monal_void_block_t) + } else { + seal.fulfill(nil) + } + } + }, forMuc:mucJid) + do { + try action() + } catch { + seal.reject(error) + } + + } + } +} + +func mucAffiliationToString(_ affiliation: String?) -> String { + if let affiliation = affiliation { + if affiliation == kMucAffiliationOwner { + return NSLocalizedString("Owner", comment:"muc affiliation") + } else if affiliation == kMucAffiliationAdmin { + return NSLocalizedString("Admin", comment:"muc affiliation") + } else if affiliation == kMucAffiliationMember { + return NSLocalizedString("Member", comment:"muc affiliation") + } else if affiliation == kMucAffiliationNone { + return NSLocalizedString("Participant", comment:"muc affiliation") + } else if affiliation == kMucAffiliationOutcast { + return NSLocalizedString("Blocked", comment:"muc affiliation") + } else if affiliation == kMucActionShowProfile { + return NSLocalizedString("Open contact details", comment:"muc members list") + } else if affiliation == kMucActionReinvite { + return NSLocalizedString("Invite again", comment:"muc invite") + } + } + return NSLocalizedString("", comment:"muc affiliation") +} + +func mucAffiliationToInt(_ affiliation: String?) -> Int { + if let affiliation = affiliation { + if affiliation == kMucAffiliationOwner { + return 1 + } else if affiliation == kMucAffiliationAdmin { + return 2 + } else if affiliation == kMucAffiliationMember { + return 3 + } else if affiliation == kMucAffiliationNone { + return 4 + } else if affiliation == kMucAffiliationOutcast { + return 5 + } else if affiliation == kMucActionShowProfile { + return 1000 + } else if affiliation == kMucActionReinvite { + return 100 + } + } + return 0 +} + +struct CollapsedPickerStyle: ViewModifier { + let accessibilityLabel: Text + func body(content: Content) -> some View { + Menu { + content + } label: { + Button(action: { }) { + HStack { + Spacer().frame(width:8) + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + .foregroundColor(.primary) + Spacer().frame(width:8) + } + .contentShape(Rectangle()) + } + .frame(width: 24, height: 20) + .accessibilityLabel(accessibilityLabel) + } + } + +} +extension View { + func collapsedPickerStyle(accessibilityLabel label: Text) -> some View { + self.modifier(CollapsedPickerStyle(accessibilityLabel:label)) + } +} + +struct TopRight: ViewModifier { + let overlay: T + public func body(content: Content) -> some View { + ZStack(alignment: .topLeading) { + content + VStack { + HStack { + Spacer() + overlay + } + Spacer() + } + } + } +} +extension View { + func addTopRight(view overlayClosure: @autoclosure @escaping () -> T) -> some View { + modifier(TopRight(overlay:overlayClosure())) + } + func addTopRight(@ViewBuilder _ overlayClosure: @escaping () -> some View) -> some View { + modifier(TopRight(overlay:overlayClosure())) + } +} + +struct MonalProminentButtonStyle: ButtonStyle { + @Environment(\.isEnabled) var isEnabled + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(10) + .background(Color.accentColor) + .foregroundColor(Color(UIColor.systemBackground)) + .fontWeight(isEnabled ? .bold : .regular) + .cornerRadius(10) + } +} + +@ViewBuilder +func buildNotificationStateLabel(_ description: Text, isWorking: Bool) -> some View { + if(isWorking == true) { + Label(title: { + description + }, icon: { + Image(systemName: "checkmark.seal") + .foregroundColor(.green) + }) + } else { + Label(title: { + description + }, icon: { + Image(systemName: "xmark.seal") + .foregroundColor(.red) + }) + } +} + +//see https://github.com/CH3COOH/TOCropViewController/blob/issue/421/Swift/CropViewControllerSwiftUIExample/ImageCropView.swift +public struct ImageCropView: UIViewControllerRepresentable { + private let configureBlock: (CropViewController) -> Void + private let originalImage: UIImage + private let onCanceled: () -> Void + private let onImageCropped: (UIImage,CGRect,Int) -> Void + + @Environment(\.presentationMode) private var presentationMode + + public init(originalImage: UIImage, configureBlock: @escaping (CropViewController) -> Void, onCanceled: @escaping () -> Void, success onImageCropped: @escaping (UIImage,CGRect,Int) -> Void) { + self.originalImage = originalImage + self.configureBlock = configureBlock + self.onCanceled = onCanceled + self.onImageCropped = onImageCropped + } + + public func makeUIViewController(context: Context) -> CropViewController { + let cropController = CropViewController(image: originalImage) + cropController.delegate = context.coordinator + configureBlock(cropController) + return cropController + } + + public func updateUIViewController(_ uiViewController: CropViewController, context: Context) { + } + + public func makeCoordinator() -> Coordinator { + Coordinator( + onDismiss: { self.presentationMode.wrappedValue.dismiss() }, + onCanceled: self.onCanceled, + onImageCropped: self.onImageCropped + ) + } + + final public class Coordinator: NSObject, CropViewControllerDelegate { + private let onDismiss: () -> Void + private let onImageCropped: (UIImage,CGRect,Int) -> Void + private let onCanceled: () -> Void + + init(onDismiss: @escaping () -> Void, onCanceled: @escaping () -> Void, onImageCropped: @escaping (UIImage,CGRect,Int) -> Void) { + self.onDismiss = onDismiss + self.onImageCropped = onImageCropped + self.onCanceled = onCanceled + } + + public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + self.onImageCropped(image, cropRect, angle) + self.onDismiss() + } + + public func cropViewController(_ cropViewController: CropViewController, didCropToCircularImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + self.onImageCropped(image, cropRect, angle) + self.onDismiss() + } + + public func cropViewController(_ cropViewController: CropViewController, didFinishCancelled cancelled: Bool) { + self.onCanceled() + self.onDismiss() + } + } +} + +//see here for some ideas used herein: https://blog.logrocket.com/adding-gifs-ios-app-flanimatedimage-swiftui/#using-flanimatedimage-with-swift +struct GIFViewer: UIViewRepresentable { + typealias UIViewType = FLAnimatedImageView + @Binding var data: Data + + func makeUIView(context: Context) -> FLAnimatedImageView { + let imageView = FLAnimatedImageView(frame:.zero) + let animatedImage = FLAnimatedImage(animatedGIFData:data) + imageView.animatedImage = animatedImage + return imageView + } + + func updateUIView(_ imageView: FLAnimatedImageView, context: Context) { + let animatedImage = FLAnimatedImage(animatedGIFData:data) + imageView.animatedImage = animatedImage + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextField, context: Context) -> CGSize? { + guard + let width = proposal.width, + let height = proposal.height + else { return nil } + return CGSize(width: width, height: height) + } +} + +//see https://www.hackingwithswift.com/books/ios-swiftui/importing-an-image-into-swiftui-using-phpickerviewcontroller +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { + + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + guard let provider = results.first?.itemProvider else { return } + + if provider.canLoadObject(ofClass: UIImage.self) { + provider.loadObject(ofClass: UIImage.self) { image, _ in + self.parent.image = image as? UIImage + } + } + } + } +} + +//see https://stackoverflow.com/a/60452526 +class DocumentPickerViewController: UIDocumentPickerViewController { + private let onDismiss: () -> Void + private let onPick: (URL) -> () + + init(supportedTypes: [UTType], onPick: @escaping (URL) -> Void, onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + self.onPick = onPick + + super.init(forOpeningContentTypes:supportedTypes, asCopy:true) + + allowsMultipleSelection = false + delegate = self + } + + required init?(coder: NSCoder) { + unreachable("init(coder:) has not been implemented") + } +} + +extension DocumentPickerViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + onPick(urls.first!) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + onDismiss() + } +} + +struct ActivityViewController: UIViewControllerRepresentable { + var activityItems: [Any] + var applicationActivities: [UIActivity]? = nil + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext) { + + } +} + +// clear button for text fields, see https://stackoverflow.com/a/58896723/3528174 +struct ClearButton: ViewModifier { + let isEditing: Bool + @Binding var text: String + + public func body(content: Content) -> some View { + HStack { + content + .accessibilitySortPriority(2) + + if isEditing, !text.isEmpty { + Button { + self.text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Color(UIColor.tertiaryLabel)) + .accessibilityLabel(Text("Clear text")) + } + .padding(.trailing, 8) + .accessibilitySortPriority(1) + } + } + } +} +//this extension contains the easy-access view modifier +extension View { + /// Puts the view in an HStack and adds a clear button to the right when the text is not empty. + func addClearButton(isEditing: Bool, text: Binding) -> some View { + modifier(ClearButton(isEditing: isEditing, text:text)) + } +} + +//see https://exyte.com/blog/swiftui-tutorial-popupview-library +struct FrameGetterModifier: ViewModifier { + @Binding var frame: CGRect + func body(content: Content) -> some View { + content + .background( + GeometryReader { proxy -> AnyView in + let rect = proxy.frame(in: .global) + // This avoids an infinite layout loop + if rect.integral != self.frame.integral { + DispatchQueue.main.async { + self.frame = rect + } + } + return AnyView(EmptyView()) + } + ) + } +} +extension View { + func frameGetter(_ frame: Binding) -> some View { + modifier(FrameGetterModifier(frame: frame)) + } +} + +struct NumberlessBadge: View { + @Binding var notificationCount: Int + private let size: Int + private let inset: Int + + var badgeSize: CGFloat { + CGFloat(integerLiteral: size) + } + + var edgeInset: CGFloat { + CGFloat(integerLiteral: inset) + } + + init(_ notificationCount: Binding, size: Int = 7, inset: Int = 1) { + self._notificationCount = notificationCount + self.size = size + self.inset = inset + } + + var body: some View { + HStack { + Spacer() + VStack { + if notificationCount > 0 { + Image(systemName: "circle.fill") + .resizable() + .frame(width: badgeSize, height: badgeSize) + .tint(.red) + .padding(.trailing, edgeInset) + .padding(.top, edgeInset) + } + Spacer() + } + } + .animation(.default, value: notificationCount) + } +} + +// //see https://stackoverflow.com/a/68291983 +// struct OverflowContentViewModifier: ViewModifier { +// @State private var contentOverflow: Bool = false +// func body(content: Content) -> some View { +// GeometryReader { geometry in +// content +// .background( +// GeometryReader { contentGeometry in +// Color.clear.onAppear { +// contentOverflow = contentGeometry.size.height > geometry.size.height +// } +// } +// ) +// .wrappedInScrollView(when: contentOverflow) +// } +// } +// } +// +// extension View { +// @ViewBuilder +// func wrappedInScrollView(when condition: Bool) -> some View { +// if condition { +// ScrollView { +// self +// } +// } else { +// self +// } +// } +// } +// +// extension View { +// func scrollOnOverflow() -> some View { +// modifier(OverflowContentViewModifier()) +// } +// } + +// lazy loading of views (e.g. when used inside a NavigationLink) with the additional ability to use a closure to modify/wrap them +// see https://stackoverflow.com/a/61234030/3528174 +struct LazyClosureView: View { + let build: () -> Content + init(_ build: @autoclosure @escaping () -> Content) { + self.build = build + } + init(withClosure build: @escaping () -> Content) { + self.build = build + } + var body: Content { + build() + } +} + +// use this to wrap a view into NavigationStack, if it should be the outermost swiftui view of a new view stack +struct AddTopLevelNavigation: View { + @Environment(\.presentationMode) private var presentationMode + @StateObject private var sizeClass: ObservableKVOWrapper + let build: () -> Content + let delegate: SheetDismisserProtocol? + + init(withDelegate delegate: SheetDismisserProtocol?, to build: @autoclosure @escaping () -> Content) { + self.build = build + self.delegate = delegate + + let activeChats = (UIApplication.shared.delegate as! MonalAppDelegate).activeChats! + self._sizeClass = StateObject(wrappedValue: ObservableKVOWrapper(activeChats.sizeClass)) + } + + var body: some View { + NavigationStack { + build() + .navigationBarTitleDisplayMode(.automatic) + .navigationBarBackButtonHidden(true) // will not be shown because swiftui does not know we navigated here from UIKit + .toolbar { +#if targetEnvironment(macCatalyst) + let shouldDisplayBackButton = true +#else + let shouldDisplayBackButton = UIUserInterfaceSizeClass(rawValue: sizeClass.horizontal) == .compact +#endif + if shouldDisplayBackButton { + ToolbarItem(placement: .topBarLeading) { + Button(action : { + //NOTE: since we can get opened from objc, we still need to support our SheetDismisserProtocol + if let delegate = self.delegate { + delegate.dismiss() + } else { + self.presentationMode.wrappedValue.dismiss() + } + }) { + Image(systemName: "arrow.backward") + } + .keyboardShortcut(.escape, modifiers: []) + } + } + } + } + } +} + +// TODO: fix those workarounds as soon as we have no storyboards anymore +struct UIKitWorkaround: View { + let build: () -> Content + init(_ build: @autoclosure @escaping () -> Content) { + self.build = build + } + init(withClosure build: @escaping () -> Content) { + self.build = build + } + var body: some View { + if(UIDevice.current.userInterfaceIdiom == .phone) { + build().navigationBarTitleDisplayMode(.inline) + } else { +#if targetEnvironment(macCatalyst) + build().navigationBarTitleDisplayMode(.inline) +#else + NavigationStack { + build() + .navigationBarTitleDisplayMode(.automatic) + } + +#endif + } + } +} + +// properties for use in Alert +struct AlertPrompt { + var title: Text = Text("") + var message: Text = Text("") + var dismissLabel: Text = Text("Close") + var dismissCallback: monal_void_block_t? = nil +} + +// properties for use in actionSheet +struct ConfirmationPrompt { + var title: Text = Text("") + var message: Text = Text("") + var buttons: [ActionSheet.Button] = [] +} + +extension View { + /// Applies the given transform. + /// + /// Useful for availability branching on view modifiers. Do not branch with any properties that may change during runtime as this will cause errors. + /// - Parameters: + /// - transform: The transform to apply to the source `View`. + /// - Returns: The view transformed by the transform. + func applyClosure(@ViewBuilder _ transform: (Self) -> Content) -> some View { + transform(self) + } +} + +public extension UIViewController { + private struct AssociatedKeys { + static var DisposeCallbackKey = "ml_disposeCallbackKey" + } + + private class DisposeCallback : NSObject { + let callback: monal_void_block_t + + init(withCallback callback: @escaping monal_void_block_t) { + self.callback = callback + } + + deinit { + self.callback() + } + } + + @objc + var ml_disposeCallback: monal_void_block_t { + get { + return withUnsafePointer(to: &AssociatedKeys.DisposeCallbackKey) { pointer in + if let callback = (objc_getAssociatedObject(self, pointer) as? DisposeCallback)?.callback { + return callback + } + unreachable("You can't get what you did not set!") + } + } + set { + withUnsafePointer(to: &AssociatedKeys.DisposeCallbackKey) { pointer in + objc_setAssociatedObject(self, pointer, DisposeCallback(withCallback: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + } +} + +// Interfaces between ObjectiveC/Storyboards and SwiftUI +@objc +class SwiftuiInterface : NSObject { + @objc(makeAccountPickerForContacts:andCallType:) + func makeAccountPicker(for contacts: [MLContact], and callType: UInt) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView:AnyView(EmptyView())) + delegate.host = host + host.rootView = AnyView(AddTopLevelNavigation(withDelegate:delegate, to:AccountPicker(contacts:contacts, callType:MLCallType(rawValue: callType)!))) + return host + } + + @objc(makeCallScreenForCall:) + func makeCallScreen(for call: MLCall) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView:AnyView(EmptyView())) + delegate.host = host + host.rootView = AnyView(AVCallUI(delegate:delegate, call:call)) + return host + } + + @objc + func makeContactDetails(_ contact: MLContact) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView:AnyView(EmptyView())) + delegate.host = host + host.rootView = AnyView(AddTopLevelNavigation(withDelegate:delegate, to:ContactDetails(delegate:delegate, contact:ObservableKVOWrapper(contact)))) + return host + } + + @objc(makeImageViewerForCurrentItem:allItems:) + func makeImageViewerFor(currentItem:[String:AnyObject], allItems: [[String:AnyObject]]) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView:AnyView(EmptyView())) + delegate.host = host + host.rootView = AnyView(MediaItemSwipeView(currentItem: currentItem, allItems: allItems)) + return host + } + + @objc + func makeOwnOmemoKeyView(_ ownContact: MLContact?) -> UIViewController { + let host = UIHostingController(rootView:AnyView(EmptyView())) + if(ownContact == nil) { + host.rootView = AnyView(UIKitWorkaround(OmemoKeysView(omemoKeys: OmemoKeysForChat(viewContact: nil)))) + } else { + host.rootView = AnyView(UIKitWorkaround(OmemoKeysView(omemoKeys: OmemoKeysForChat(viewContact: ObservableKVOWrapper(ownContact!))))) + } + return host + } + + @objc + func makeAccountRegistration(_ registerData: [String:AnyObject]?) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView:AnyView(EmptyView())) + delegate.host = host +#if IS_QUICKSY + host.rootView = AnyView(Quicksy_RegisterAccount(delegate:delegate)) +#else + host.rootView = AnyView(AddTopLevelNavigation(withDelegate:delegate, to:RegisterAccount(delegate:delegate, registerData:registerData))) +#endif + return host + } + + @objc + func makeServerDetailsView(for xmppAccount: xmpp) -> UIViewController { + let host = UIHostingController(rootView:AnyView(EmptyView())) + host.rootView = AnyView(ServerDetails(xmppAccount: xmppAccount)) + return host + } + + @objc + func makeBlockedUsersView(for xmppAccount: xmpp) -> UIViewController { + let host = UIHostingController(rootView:AnyView(EmptyView())) + host.rootView = AnyView(BlockedUsers(xmppAccount: xmppAccount)) + return host + } + + @objc + func makePasswordMigration(_ needingMigration: [[String:NSObject]]) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView:AnyView(EmptyView())) + delegate.host = host + host.rootView = AnyView(AddTopLevelNavigation(withDelegate:delegate, to:PasswordMigration(delegate:delegate, needingMigration:needingMigration))) + return host + } + + @objc(makeAddContactViewWithDismisser:) + func makeAddContactView(dismisser: @escaping (MLContact) -> ()) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView:AnyView(EmptyView())) + delegate.host = host + host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: AddContactMenu(delegate: delegate, dismissWithNewContact: dismisser))) + return host + } + + @objc + func makeAddContactView(forJid jid:String, preauthToken: String?, prefillAccount: xmpp?, andOmemoFingerprints omemoFingerprints: [NSNumber:Data]?, withDismisser dismisser: @escaping (MLContact) -> ()) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView:AnyView(EmptyView())) + delegate.host = host + host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: AddContactMenu(delegate: delegate, dismissWithNewContact: dismisser, prefillJid: jid, preauthToken: preauthToken, prefillAccount: prefillAccount, omemoFingerprints: omemoFingerprints))) + return host + } + + @objc(makeContactsViewWithDismisser:onButton:) + func makeContactsView(dismisser: @escaping (MLContact) -> (), button: UIBarButtonItem) -> UIViewController { + let delegate = SheetDismisserProtocol() + let host = UIHostingController(rootView: AnyView(EmptyView())) + let contactsView = ContactsView(contacts: Contacts(), delegate: delegate, dismissWithContact: dismisser) + delegate.host = host + host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: contactsView)) + host.modalPresentationStyle = .popover + host.popoverPresentationController?.sourceItem = button + host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: 600)) + return host + } + + @objc + func makeView(name: String) -> UIViewController { + let delegate = SheetDismisserProtocol() + var host: UIHostingController? = nil + //let host = UIHostingController(rootView:AnyView(EmptyView())) + switch(name) { // TODO names are currently taken from the segue identifier, an enum would be nice once everything is ported to SwiftUI + case "DebugView": + host = UIHostingController(rootView:AnyView(UIKitWorkaround(DebugView()))) + case "WelcomeLogIn": + host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate:delegate, to:WelcomeLogIn(delegate:delegate)))) + case "LogIn": + host = UIHostingController(rootView:AnyView(UIKitWorkaround(WelcomeLogIn(delegate:delegate)))) + case "AdvancedLogIn": + host = UIHostingController(rootView:AnyView(UIKitWorkaround(WelcomeLogIn(advancedMode: true, delegate: delegate)))) + case "ChatPlaceholder": + host = UIHostingController(rootView:AnyView(ChatPlaceholder())) + case "GeneralSettings" : + host = UIHostingController(rootView:AnyView(UIKitWorkaround(GeneralSettings()))) + case "ActiveChatsGeneralSettings": + host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate: delegate, to: GeneralSettings()))) + case "ActiveChatsNotificationSettings": + host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate: delegate, to: NotificationSettings()))) + case "OnboardingView": + host = UIHostingController(rootView:AnyView(createOnboardingView(delegate:delegate))) + + default: + unreachable() + } + delegate.host = host! + return host! + } +} diff --git a/Monal/Classes/UIColor+Extension.h b/Monal/Classes/UIColor+Extension.h new file mode 100644 index 0000000..b3b489f --- /dev/null +++ b/Monal/Classes/UIColor+Extension.h @@ -0,0 +1,17 @@ +// +// UIColor+Extension.h +// Monal +// +// Created by Thilo Molitor on 04.11.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIColor (Extension) +-(BOOL) isLightColor; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/UIColor+Extension.m b/Monal/Classes/UIColor+Extension.m new file mode 100644 index 0000000..f0dbed4 --- /dev/null +++ b/Monal/Classes/UIColor+Extension.m @@ -0,0 +1,26 @@ +// +// UIColor+Extension.m +// Monal +// +// Created by Thilo Molitor on 04.11.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +#import "UIColor+Extension.h" + +@implementation UIColor (Extension) +-(BOOL) isLightColor +{ + CGFloat colorBrightness = 0; + CGColorSpaceRef colorSpace = CGColorGetColorSpace(self.CGColor); + CGColorSpaceModel colorSpaceModel = CGColorSpaceGetModel(colorSpace); + if(colorSpaceModel == kCGColorSpaceModelRGB) + { + const CGFloat* componentColors = CGColorGetComponents(self.CGColor); + colorBrightness = ((componentColors[0] * 299) + (componentColors[1] * 587) + (componentColors[2] * 114)) / 1000; + } + else + [self getWhite:&colorBrightness alpha:0]; + return (colorBrightness >= .5f); +} +@end diff --git a/Monal/Classes/WebRTCClient.swift b/Monal/Classes/WebRTCClient.swift new file mode 100644 index 0000000..5805a5f --- /dev/null +++ b/Monal/Classes/WebRTCClient.swift @@ -0,0 +1,434 @@ +// +// WebRTCClient.swift +// WebRTC +// +// Created by Stasel on 20/05/2018. +// Copyright © 2018 Stasel. All rights reserved. +// Copyright © 2022 Thilo Molitor. All rights reserved. +// + +import WebRTC + +@objc +protocol WebRTCClientDelegate: AnyObject { + func webRTCClient(_ client: WebRTCClient, didDiscoverLocalCandidate candidate: RTCIceCandidate) + func webRTCClient(_ client: WebRTCClient, didChangeConnectionState state: RTCIceConnectionState) + func webRTCClient(_ client: WebRTCClient, didReceiveData data: Data) +} + +@objc +final class WebRTCClient: NSObject { + + // The `RTCPeerConnectionFactory` is in charge of creating new RTCPeerConnection instances. + // A new RTCPeerConnection should be created every new call, but the factory is shared. + private static let factory: RTCPeerConnectionFactory = { + RTCInitializeSSL() + let videoEncoderFactory = RTCDefaultVideoEncoderFactory() + let videoDecoderFactory = RTCDefaultVideoDecoderFactory() + return RTCPeerConnectionFactory(encoderFactory: videoEncoderFactory, decoderFactory: videoDecoderFactory) + }() + + @objc var delegate: WebRTCClientDelegate? + @objc public let peerConnection: RTCPeerConnection + private let rtcAudioSession = RTCAudioSession.sharedInstance() + private let audioQueue = DispatchQueue(label: "audio") + private let streamId = "-" + private var mediaConstrains: [String:String] = [:] + private var videoCapturer: RTCVideoCapturer? + private var localVideoTrack: RTCVideoTrack? + private var remoteVideoTrack: RTCVideoTrack? + private var localDataChannel: RTCDataChannel? + private var remoteDataChannel: RTCDataChannel? + + @available(*, unavailable) + override init() { + unreachable("WebRTCClient:init is unavailable") + } + + deinit { + DDLogDebug("Deinit of webrtc client for delegate: \(String(describing:self.delegate))") + } + + @objc + static func createPeerConnection(iceServers: [RTCIceServer], forceRelay: Bool) -> RTCPeerConnection? { + let config = RTCConfiguration() + if forceRelay { + config.iceTransportPolicy = .relay + } else { + config.iceTransportPolicy = .all + } + config.iceServers = iceServers + + // Unified plan is more superior than planB + config.sdpSemantics = .unifiedPlan + + // gatherContinually will let WebRTC to listen to any network changes and send any new candidates to the other client + config.continualGatheringPolicy = .gatherContinually + + //config.tcpCandidatePolicy = .disabled // XEP-0176 doesn't support tcp + config.rtcpMuxPolicy = .negotiate + + //aggressive pings to detect wifi - mobile handoffs better + config.iceConnectionReceivingTimeout = 1000 + config.iceBackupCandidatePairPingInterval = 2000 + + //bigger jitter buffer for better audio quality (2s, default: 1s) + config.audioJitterBufferMaxPackets = 100; + + // Define media constraints. DtlsSrtpKeyAgreement is required to be true to be able to connect with web browsers. + let constraints = RTCMediaConstraints(mandatoryConstraints: [ + "DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue + ], optionalConstraints: [ + "echoCancellation": kRTCMediaConstraintsValueTrue, + "noiseSuppression": kRTCMediaConstraintsValueTrue, + "VoiceActivityDetection": kRTCMediaConstraintsValueTrue + ]) + + DDLogInfo("iceConnectionReceivingTimeout=\(config.iceConnectionReceivingTimeout), iceBackupCandidatePairPingInterval=\(config.iceBackupCandidatePairPingInterval)"); + return WebRTCClient.factory.peerConnection(with: config, constraints: constraints, delegate: nil) + } + + @objc + required init(iceServers: [RTCIceServer], audioOnly: Bool, forceRelay: Bool) { +#if IS_ALPHA + RTCSetMinDebugLogLevel(.verbose) +#else + RTCSetMinDebugLogLevel(.info) +#endif + + var peerConnection = WebRTCClient.createPeerConnection(iceServers: iceServers, forceRelay: forceRelay) + if peerConnection == nil { + // try again with empty ice server list + DDLogError("Broken ice server list, retrying without any ice servers: \(String(describing:iceServers))") + peerConnection = WebRTCClient.createPeerConnection(iceServers: [], forceRelay: forceRelay) + guard peerConnection != nil else { + //this should never happen --> crash + unreachable("Could not create new RTCPeerConnection") + } + } + + self.peerConnection = peerConnection! + super.init() + + self.createMediaSenders(audioOnly: audioOnly) + self.peerConnection.delegate = self + + if audioOnly { + self.mediaConstrains = [ + kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, + kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueFalse, + ] + } else { + self.mediaConstrains = [ + kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, + kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue, + ] + } + } + + // MARK: Signaling + @objc + func offer(completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { + let constrains = RTCMediaConstraints(mandatoryConstraints: self.mediaConstrains, + optionalConstraints: nil) + self.peerConnection.offer(for: constrains) { (sdp, error) in + guard let sdp = sdp else { + return + } + + self.peerConnection.setLocalDescription(sdp, completionHandler: { (error) in + completion(sdp) + }) + } + } + + @objc + func answer(completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { + let constrains = RTCMediaConstraints(mandatoryConstraints: self.mediaConstrains, + optionalConstraints: nil) + self.peerConnection.answer(for: constrains) { (sdp, error) in + guard let sdp = sdp else { + return + } + + self.peerConnection.setLocalDescription(sdp, completionHandler: { (error) in + completion(sdp) + }) + } + } + + @objc + func setRemoteSdp(_ remoteSdp: RTCSessionDescription, completion: @escaping (Error?) -> ()) { + self.peerConnection.setRemoteDescription(remoteSdp, completionHandler: completion) + } + + @objc + func setRemoteCandidate(_ remoteCandidate : RTCIceCandidate, completion: @escaping (Error?) -> ()) { + self.peerConnection.add(remoteCandidate, completionHandler: completion) + } + + // MARK: Media + @objc + func startCaptureLocalVideo(renderer: RTCVideoRenderer, andCameraPosition position: AVCaptureDevice.Position) { + guard let capturer = self.videoCapturer as? RTCCameraVideoCapturer else { + return + } + + guard + let frontCamera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == position }), + + // choose highest res + let format = (RTCCameraVideoCapturer.supportedFormats(for: frontCamera).sorted { (f1, f2) -> Bool in + let width1 = CMVideoFormatDescriptionGetDimensions(f1.formatDescription).width + let width2 = CMVideoFormatDescriptionGetDimensions(f2.formatDescription).width + return width1 < width2 + }).last, + + // choose highest fps + let fps = (format.videoSupportedFrameRateRanges.sorted { return $0.maxFrameRate < $1.maxFrameRate }.last) else { + return + } + + capturer.startCapture(with: frontCamera, + format: format, + fps: Int(fps.maxFrameRate)) + + self.localVideoTrack?.add(renderer) + } + + @objc + func stopCaptureLocalVideo() { + guard let capturer = self.videoCapturer as? RTCCameraVideoCapturer else { + return + } + capturer.stopCapture() + } + + @objc + func renderRemoteVideo(to renderer: RTCVideoRenderer) { + self.remoteVideoTrack?.add(renderer) + } + + @objc + func addVideo() { + let videoTrack = self.createVideoTrack() + self.localVideoTrack = videoTrack + self.peerConnection.add(videoTrack, streamIds: [self.streamId]) + self.remoteVideoTrack = self.peerConnection.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack + } + + @objc + func configureAudioSession() { + DDLogInfo("Configuring shared rtcAudioSession...") + self.rtcAudioSession.lockForConfiguration() + do { + try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord) + try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat) + } catch let error { + DDLogDebug("Error changeing AVAudioSession category: \(error)") + } + self.rtcAudioSession.useManualAudio = true + self.rtcAudioSession.isAudioEnabled = false + self.rtcAudioSession.unlockForConfiguration() + } + + private func createMediaSenders(audioOnly: Bool) { + //Audio + let audioTrack = self.createAudioTrack() + self.peerConnection.add(audioTrack, streamIds: [self.streamId]) + + //Video + if !audioOnly { + // see https://stackoverflow.com/a/43765394 + self.addVideo() + } + + //we don't use any data channels for A/V calls +// // Data +// if let dataChannel = createDataChannel() { +// dataChannel.delegate = self +// self.localDataChannel = dataChannel +// } + } + + private func createAudioTrack() -> RTCAudioTrack { + let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) + let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains) + let audioTrack = WebRTCClient.factory.audioTrack(with: audioSource, trackId: "audio-"+UUID().uuidString) + return audioTrack + } + + private func createVideoTrack() -> RTCVideoTrack { + let videoSource = WebRTCClient.factory.videoSource() + + #if targetEnvironment(simulator) + self.videoCapturer = RTCFileVideoCapturer(delegate: videoSource) + #else + self.videoCapturer = RTCCameraVideoCapturer(delegate: videoSource) + #endif + + //see https://bugs.chromium.org/p/webrtc/issues/detail?id=10006#c2 + let aClass: AnyClass! = object_getClass(self.videoCapturer!) + let bClass: AnyClass! = object_getClass(self) + if self.videoCapturer!.responds(to: NSSelectorFromString("updateOrientation")) { + DDLogWarn("Swizzling method 'updateOrientation' of video capturer...") + let swizzledMethod = class_getInstanceMethod(aClass, NSSelectorFromString("updateOrientation")) + let replacementMethod = class_getInstanceMethod(bClass, NSSelectorFromString("dummyUpdateOrientation")) + let replacementImplementation = method_getImplementation(replacementMethod!) + method_setImplementation(swizzledMethod!, replacementImplementation) + } + + let videoTrack = WebRTCClient.factory.videoTrack(with: videoSource, trackId: "video-"+UUID().uuidString) + return videoTrack + } + + @objc + func dummyUpdateOrientation() { + DDLogDebug("Ignoring device orientation change in webrtc...") + } + + // MARK: Data Channels + private func createDataChannel() -> RTCDataChannel? { + let config = RTCDataChannelConfiguration() + guard let dataChannel = self.peerConnection.dataChannel(forLabel: "WebRTCData", configuration: config) else { + DDLogDebug("Warning: Couldn't create data channel.") + return nil + } + return dataChannel + } + + @objc + func sendData(_ data: Data) { + let buffer = RTCDataBuffer(data: data, isBinary: true) + self.remoteDataChannel?.sendData(buffer) + } +} + +extension WebRTCClient: RTCPeerConnectionDelegate { + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) { + DDLogDebug("peerConnection new signaling state: \(stateChanged)") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { + DDLogDebug("peerConnection did add stream") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) { + DDLogDebug("peerConnection did remove stream") + } + + func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { + DDLogDebug("peerConnection should negotiate") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + DDLogDebug("peerConnection new connection state: \(newState)") + self.delegate?.webRTCClient(self, didChangeConnectionState: newState) + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) { + DDLogDebug("peerConnection new gathering state: \(newState)") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { + self.delegate?.webRTCClient(self, didDiscoverLocalCandidate: candidate) + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) { + DDLogDebug("peerConnection did remove candidate(s)") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { + DDLogDebug("peerConnection did open data channel") + self.remoteDataChannel = dataChannel + } +} +extension WebRTCClient { + private func setTrackEnabled(_ type: T.Type, isEnabled: Bool) { + peerConnection.transceivers + .compactMap { return $0.sender.track as? T } + .forEach { $0.isEnabled = isEnabled } + } +} + +// MARK: - Video control +extension WebRTCClient { + @objc + func hideVideo() { + self.setVideoEnabled(false) + } + @objc + func showVideo() { + self.setVideoEnabled(true) + } + private func setVideoEnabled(_ isEnabled: Bool) { + setTrackEnabled(RTCVideoTrack.self, isEnabled: isEnabled) + } +} + +// MARK:- Audio control +extension WebRTCClient { + @objc + func muteAudio() { + self.setAudioEnabled(false) + } + + @objc + func unmuteAudio() { + self.setAudioEnabled(true) + } + + // Fallback to the default playing device: headphones/bluetooth/ear speaker + @objc + func speakerOff() { + self.audioQueue.async { [weak self] in + guard let self = self else { + return + } + + self.rtcAudioSession.lockForConfiguration() + do { + try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord) + try self.rtcAudioSession.overrideOutputAudioPort(.none) + } catch let error { + DDLogDebug("Error setting AVAudioSession category: \(error)") + } + self.rtcAudioSession.unlockForConfiguration() + } + } + + // Force speaker + @objc + func speakerOn() { + self.audioQueue.async { [weak self] in + guard let self = self else { + return + } + + self.rtcAudioSession.lockForConfiguration() + do { + try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord) + try self.rtcAudioSession.overrideOutputAudioPort(.speaker) + try self.rtcAudioSession.setActive(true) + } catch let error { + DDLogDebug("Couldn't force audio to speaker: \(error)") + } + self.rtcAudioSession.unlockForConfiguration() + } + } + + private func setAudioEnabled(_ isEnabled: Bool) { + setTrackEnabled(RTCAudioTrack.self, isEnabled: isEnabled) + } +} + +extension WebRTCClient: RTCDataChannelDelegate { + func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { + DDLogDebug("dataChannel did change state: \(dataChannel.readyState)") + } + + func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) { + self.delegate?.webRTCClient(self, didReceiveData: buffer.data) + } +} diff --git a/Monal/Classes/WelcomeLogIn.swift b/Monal/Classes/WelcomeLogIn.swift new file mode 100644 index 0000000..3ca0bed --- /dev/null +++ b/Monal/Classes/WelcomeLogIn.swift @@ -0,0 +1,402 @@ +// +// WelcomeLogIn.swift +// Monal +// +// Created by CC on 22.04.22. +// Copyright © 2022 Monal.im. All rights reserved. +// + +struct WelcomeLogIn: View { + private static let credFaultyPattern = "^.+@.+\\..{2,}$" + + var advancedMode: Bool = false + var delegate: SheetDismisserProtocol + + @State private var isEditingJid: Bool = false + @State private var jid: String = "" + @State private var isEditingPassword: Bool = false + @State private var password: String = "" + + @State private var hardcodedServer: String = "" + @State private var hardcodedPort: String = "5222" + @State private var allowPlainAuth: Bool = false + @State private var forceDirectTLS: Bool = false + + @State private var showAlert = false + @State private var showQRCodeScanner = false + + // login related + @State private var currentTimeout: DispatchTime? = nil + @State private var errorObserverEnabled = false + @State private var newAccountID: NSNumber? = nil + @State private var loginComplete = false + @State private var isLoadingOmemoBundles = false + + @State private var alertPrompt = AlertPrompt() + @StateObject private var overlay = LoadingOverlayState() + + #if IS_ALPHA + let appLogoId = "AlphaAppLogo" + #elseif IS_QUICKSY + let appLogoId = "QuicksyAppLogo" + #else + let appLogoId = "AppLogo" + #endif + + private var credentialsEnteredAlert: Bool { + alertPrompt.title = Text("Empty Values!") + alertPrompt.message = Text("Please make sure you have entered both a username and password.") + alertPrompt.dismissLabel = Text("Close") + return credentialsEntered + } + + private var credentialsFaultyAlert: Bool { + alertPrompt.title = Text("Invalid Credentials!") + alertPrompt.message = Text("Your XMPP jid should be in in the format user@domain.tld. For special configurations, use manual setup.") + alertPrompt.dismissLabel = Text("Close") + return credentialsFaulty + } + + private var credentialsExistAlert: Bool { + alertPrompt.title = Text("Duplicate jid!") + alertPrompt.message = Text("This account already exists in Monal.") + alertPrompt.dismissLabel = Text("Close") + return credentialsExist + } + + private func showTimeoutAlert() { + DDLogVerbose("Showing timeout alert...") + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Timeout Error") + alertPrompt.message = Text("We were not able to connect your account. Please check your username and password and make sure you are connected to the internet.") + alertPrompt.dismissLabel = Text("Close") + showAlert = true + } + + private func showSuccessAlert() { + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Success!") + alertPrompt.message = Text("You are set up and connected.") + alertPrompt.dismissLabel = Text("Close") + showAlert = true + } + + private func showLoginErrorAlert(errorMessage: String) { + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Error") + alertPrompt.message = Text(String(format: NSLocalizedString("We were not able to connect your account. Please check your username and password and make sure you are connected to the internet.\n\nTechnical error message: %@", comment: ""), errorMessage)) + alertPrompt.dismissLabel = Text("Close") + showAlert = true + } + + private func showPlainAuthWarningAlert() { + alertPrompt.title = Text("Warning") + alertPrompt.message = Text("If you turn this on, you will no longer be safe from man-in-the-middle attacks. Such attacks enable the adversary to manipulate your incoming and outgoing messages, add their own OMEMO keys, change your account details and even know or change your password!\n\nYou should rather switch to another server than turning this on.") + alertPrompt.dismissLabel = Text("Understood") + showAlert = true + } + + private var jidDomainPart: String { + let jidComponents = HelperTools.splitJid(jid) + return jidComponents["host"] ?? "" + } + + private var credentialsEntered: Bool { + !jid.isEmpty && !password.isEmpty + } + + private var credentialsFaulty: Bool { + jid.range(of: WelcomeLogIn.credFaultyPattern, options: .regularExpression) == nil + } + + private var credentialsExist: Bool { + let components = jid.components(separatedBy: "@") + return DataLayer.sharedInstance().doesAccountExistUser(components[0], andDomain: components[1]) + } + + private var loginButtonDisabled: Bool { + !credentialsEntered || credentialsFaulty + } + + private func startLoginTimeout() { + let newTimeout = DispatchTime.now() + 30.0 + currentTimeout = newTimeout + DispatchQueue.main.asyncAfter(deadline: newTimeout) { + if newTimeout == self.currentTimeout { + DDLogWarn("First login timeout triggered...") + if self.newAccountID != nil { + DDLogVerbose("Removing account...") + MLXMPPManager.sharedInstance().removeAccount(forAccountID: self.newAccountID!) + self.newAccountID = nil + } + self.currentTimeout = nil + showTimeoutAlert() + } + } + } + + var body: some View { + ZStack { + /// Ensure the ZStack takes the entire area + Color.clear + + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading) { + if !advancedMode { + VStack { + HStack { + Image(decorative: appLogoId) + .resizable() + .frame(width: CGFloat(120), height: CGFloat(120), alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding() + + Text("Log in to your existing account or register a new account. If required you will find more advanced options in Monal settings.") + .padding() + .padding(.leading, -16.0) + } + } + .frame(maxWidth: .infinity) + .background(Color(UIColor.systemBackground)) + } + + Form { + Text("I already have an account:") + .listRowSeparator(.hidden) + + TextField( + NSLocalizedString("user@domain.tld", comment: "placeholder when adding account"), + text: Binding( + get: { self.jid }, + set: { string in self.jid = string.lowercased().replacingOccurrences(of: " ", with: "") } + ), + onEditingChanged: { isEditingJid = $0 } + ) + .textInputAutocapitalization(.never) + .autocapitalization(.none) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .addClearButton(isEditing: isEditingJid, text: $jid) + .listRowSeparator(.hidden) + + SecureField(NSLocalizedString("Password", comment: "placeholder when adding account"), text: $password) + .addClearButton(isEditing: !password.isEmpty, text: $password) + .listRowSeparator(.hidden) + + if advancedMode { + TextField("Optional Hardcoded Hostname", text: $hardcodedServer) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .addClearButton(isEditing: !hardcodedServer.isEmpty, text: $hardcodedServer) + .listRowSeparator(.hidden) + + if !hardcodedServer.isEmpty { + HStack { + Text("Port") + Spacer() + TextField("Optional Hardcoded Port", text: $hardcodedPort) + .keyboardType(.numberPad) + .addClearButton(isEditing: !hardcodedPort.isEmpty, text: $hardcodedPort) + .onDisappear { + hardcodedPort = "5222" + } + } + .listRowSeparator(.hidden) + + Toggle(isOn: $forceDirectTLS) { + Text("Always use direct TLS, not STARTTLS") + } + .onDisappear { + forceDirectTLS = false + } + } + + Toggle(isOn: $allowPlainAuth) { + Text("Allow MITM-prone PLAIN authentication") + } + // TODO: use the SCRAM preload list instead of hardcoding servers + .disabled(["conversations.im"].contains(jidDomainPart.lowercased())) + .onChange(of: jid) { _ in + if ["conversations.im"].contains(jidDomainPart.lowercased()) { + allowPlainAuth = false + } + } + .onChange(of: allowPlainAuth) { _ in + if allowPlainAuth { + showPlainAuthWarningAlert() + } + } + } + + HStack { + Button(action: { + showAlert = !credentialsEnteredAlert || credentialsFaultyAlert || credentialsExistAlert + + if !showAlert { + startLoginTimeout() + showLoadingOverlay(overlay, headline: NSLocalizedString("Logging in", comment: "")) + self.errorObserverEnabled = true + if advancedMode { + self.newAccountID = MLXMPPManager.sharedInstance().login(self.jid, password: self.password, hardcodedServer: self.hardcodedServer, hardcodedPort: self.hardcodedPort, forceDirectTLS: self.forceDirectTLS, allowPlainAuth: self.allowPlainAuth) + } else { + self.newAccountID = MLXMPPManager.sharedInstance().login(self.jid, password: self.password) + } + if self.newAccountID == nil { + currentTimeout = nil // <- disable timeout on error + errorObserverEnabled = false + showLoginErrorAlert(errorMessage: NSLocalizedString("Account already configured in Monal!", comment: "")) + self.newAccountID = nil + } + } + }) { + Text("Login") + .frame(maxWidth: .infinity) + .padding(9.0) + .background(Color(UIColor.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(BorderlessButtonStyle()) + .disabled(loginButtonDisabled) + .alert(isPresented: $showAlert) { + Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton: .default(alertPrompt.dismissLabel, action: { + if self.loginComplete == true { + self.delegate.dismiss() + } + })) + } + + if !advancedMode { + // Just sets the credential in jid and password variables and shows them in the input fields + // so user can control what they scanned and if o.k. login via the "Login" button. + Button(action: { + showQRCodeScanner = true + }) { + Image(systemName: "qrcode") + .frame(maxHeight: .infinity) + .padding(9.0) + .background(Color(UIColor.tertiarySystemFill)) + .foregroundColor(.primary) + .clipShape(Circle()) + } + .buttonStyle(BorderlessButtonStyle()) + .sheet(isPresented: $showQRCodeScanner) { + Text("QR-Code Scanner").font(.largeTitle.weight(.bold)) + // Get existing credentials from QR and put values in jid and password + MLQRCodeScanner( + handleLogin: { jid, password in + self.jid = jid + self.password = password + }, handleClose: { + self.showQRCodeScanner = false + } + ) + } + } + } + .listRowSeparator(.hidden, edges: .top) + // Align the (bottom) list row separator to the very left + .alignmentGuide(.listRowSeparatorLeading) { _ in + 0 + } + + NavigationLink(destination: LazyClosureView(RegisterAccount(delegate: self.delegate))) { + Text("Register a new account") + .foregroundColor(Color.accentColor) + } + + if DataLayer.sharedInstance().enabledAccountCnts() == 0 { + Button(action: { + self.delegate.dismiss() + }) { + Text("Set up account later") + .frame(maxWidth: .infinity) + .padding(.top, 10.0) + .padding(.bottom, 9.0) + .foregroundColor(Color(UIColor.systemGray)) + } + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + UITableView.appearance().tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 30)) + } + } + /// Sets the minimum frame height to the available height of the scrollview and the maxHeight to infinity + .frame(minHeight: proxy.size.height, maxHeight: .infinity) + } + } + } + .addLoadingOverlay(overlay) + .navigationTitle(advancedMode ? Text("Add Account (advanced)") : Text("Welcome")) + .navigationBarTitleDisplayMode(advancedMode ? .inline : .large) + .onDisappear { UITableView.appearance().tableHeaderView = nil } // why that?? + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kXMPPError")).receive(on: RunLoop.main)) { notification in + if self.errorObserverEnabled == false { + return + } + if let xmppAccount = notification.object as? xmpp, let newAccountID: NSNumber = self.newAccountID, let errorMessage = notification.userInfo?["message"] as? String { + if xmppAccount.accountID.intValue == newAccountID.intValue { + DispatchQueue.main.async { + currentTimeout = nil // <- disable timeout on error + errorObserverEnabled = false + showLoginErrorAlert(errorMessage: errorMessage) + MLXMPPManager.sharedInstance().removeAccount(forAccountID: newAccountID) + self.newAccountID = nil + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMLResourceBoundNotice")).receive(on: RunLoop.main)) { notification in + if let xmppAccount = notification.object as? xmpp, let newAccountID: NSNumber = self.newAccountID { + if xmppAccount.accountID.intValue == newAccountID.intValue { + DispatchQueue.main.async { + currentTimeout = nil // <- disable timeout on successful connection + self.errorObserverEnabled = false + showLoadingOverlay(overlay, headline: NSLocalizedString("Loading contact list", comment: "")) + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalUpdateBundleFetchStatus")).receive(on: RunLoop.main)) { notification in + if let notificationAccountID = notification.userInfo?["accountID"] as? NSNumber, let completed = notification.userInfo?["completed"] as? NSNumber, let all = notification.userInfo?["all"] as? NSNumber, let newAccountID: NSNumber = self.newAccountID { + if notificationAccountID.intValue == newAccountID.intValue { + isLoadingOmemoBundles = true + showLoadingOverlay( + overlay, + headline: NSLocalizedString("Loading omemo bundles", comment: ""), + description: String(format: NSLocalizedString("Loading omemo bundles: %@ / %@", comment: ""), completed, all) + ) + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalFinishedOmemoBundleFetch")).receive(on: RunLoop.main)) { notification in + if let notificationAccountID = notification.userInfo?["accountID"] as? NSNumber, let newAccountID: NSNumber = self.newAccountID { + if notificationAccountID.intValue == newAccountID.intValue && isLoadingOmemoBundles { + DispatchQueue.main.async { + self.loginComplete = true + showSuccessAlert() + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalFinishedCatchup")).receive(on: RunLoop.main)) { notification in + if let xmppAccount = notification.object as? xmpp, let newAccountID: NSNumber = self.newAccountID { + if xmppAccount.accountID.intValue == newAccountID.intValue && !isLoadingOmemoBundles { + DispatchQueue.main.async { + self.loginComplete = true + showSuccessAlert() + } + } + } + } + } +} + +struct WelcomeLogIn_Previews: PreviewProvider { + static var delegate = SheetDismisserProtocol() + static var previews: some View { + WelcomeLogIn(delegate: delegate) + } +} diff --git a/Monal/Classes/XMPPDataForm.h b/Monal/Classes/XMPPDataForm.h new file mode 100644 index 0000000..6bd2e69 --- /dev/null +++ b/Monal/Classes/XMPPDataForm.h @@ -0,0 +1,72 @@ +// +// XMPPDataForm.h +// monalxmpp +// +// Created by Thilo Molitor on 12.10.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#ifndef XMPPDataForm_h +#define XMPPDataForm_h + +#import +#import "MLXMLNode.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface XMPPDataForm : MLXMLNode + +-(id) initWithType:(NSString*) type andFormType:(NSString*) formType; +-(id) initWithType:(NSString*) type formType:(NSString*) formType andDictionary:(NSDictionary*) vars; + +@property (atomic, strong) NSString* type; +@property (atomic, strong) NSString* formType; +@property (atomic, strong) NSString* _Nullable title; +@property (atomic, strong) NSString* _Nullable instructions; +-(MLXMLNode*) setFieldWithDictionary:(NSDictionary*) field; +-(MLXMLNode*) setFieldWithDictionary:(NSDictionary*) field atIndex:(NSNumber* _Nullable) index; +-(MLXMLNode*) setField:(NSString*) name withValue:(NSString*) value; +-(MLXMLNode*) setField:(NSString* _Nonnull) name withValue:(NSString* _Nonnull) value atIndex:(NSNumber* _Nullable) index; +-(MLXMLNode*) setField:(NSString*) name withType:(NSString* _Nullable) type andValue:(NSString*) value; +-(MLXMLNode*) setField:(NSString* _Nonnull) name withType:(NSString* _Nullable) type andValue:(NSString* _Nonnull) value atIndex:(NSNumber* _Nullable) index; +-(NSDictionary* _Nullable) getField:(NSString*) name; +-(NSDictionary* _Nullable) getField:(NSString* _Nonnull) name atIndex:(NSNumber* _Nullable) index; +-(void) removeField:(NSString*) name; +-(void) removeField:(NSString* _Nonnull) name atIndex:(NSNumber* _Nullable) index; +@property (strong, readonly) NSString* description; + +//*** NSMutableArray interface (not complete, only indexed subscript access methods supported) + +-(id) objectAtIndexedSubscript:(NSInteger) idx; +-(void) setObject:(id _Nullable) obj atIndexedSubscript:(NSInteger) idx; + +//*** NSMutableDictionary interface (not complete, but nearly complete) +//for multi-item forms all of these methods will operate on the first item only, with one exception: +//count will return the count of items for multi-item forms + +//will return the count of items for a multi-item form +@property(readonly) NSUInteger count; +@property(readonly, copy) NSArray* allKeys; +@property(readonly, copy) NSArray* allValues; +-(id _Nullable) objectForKeyedSubscript:(NSString*) key; +-(void) setObject:(id _Nullable) obj forKeyedSubscript:(NSString*) key; +//for multi-item forms it will only return the list of var names of the first item +//(as according to XEP-0004 all items should have the same set of field nodes --> this should contain all var names possible in any item) +-(NSArray*) allKeys; +-(NSArray*) allValues; +-(NSArray*) allKeysForObject:(id) anObject; +-(id) valueForKey:(NSString*) key; +-(id) objectForKey:(NSString*) key; +-(void) removeObjectForKey:(NSString*) key; +-(void) removeAllObjects; +-(void) removeObjectsForKeys:(NSArray*) keyArray; +-(void) setObject:(NSString*) value forKey:(NSString*) key; +-(void) setValue:(NSString* _Nullable) value forKey:(NSString*) key; +-(void) addEntriesFromDictionary:(NSDictionary*) vars; +-(void) setDictionary:(NSDictionary*) vars; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* XMPPDataForm_h */ diff --git a/Monal/Classes/XMPPDataForm.m b/Monal/Classes/XMPPDataForm.m new file mode 100644 index 0000000..b03a08c --- /dev/null +++ b/Monal/Classes/XMPPDataForm.m @@ -0,0 +1,480 @@ +// +// XMPPDataForm.m +// monalxmpp +// +// Created by ich on 12.10.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "XMPPDataForm.h" +#import "HelperTools.h" + +@interface MLXMLNode() +@property (atomic, strong, readwrite) NSString* element; +-(void) invalidateUpstreamCache; +@end + +@implementation XMPPDataForm + +static NSRegularExpression* dataFormQueryRegex; + ++(void) initialize +{ + dataFormQueryRegex = [NSRegularExpression regularExpressionWithPattern:@"^(\\{(\\*|[^}]+)\\})?([!a-zA-Z0-9_:-]+|\\*)?(\\[([0-9]+)\\])?(@[a-zA-Z0-9_:#-]+|&[a-zA-Z0-9_:#-]+)?" options:0 error:nil]; +} + +//this simple init is not public api because type and form type are mandatory in xep-0004 +-(id _Nonnull) init +{ + self = [super init]; + self.element = @"x"; + [self setXMLNS:@"jabber:x:data"]; + return self; +} + +-(id _Nonnull) initWithType:(NSString* _Nonnull) type andFormType:(NSString* _Nonnull) formType +{ + self = [self init]; + self.attributes[@"type"] = type; + [self setField:@"FORM_TYPE" withType:@"hidden" andValue:formType]; + return self; +} + +-(id _Nonnull) initWithType:(NSString* _Nonnull) type formType:(NSString* _Nonnull) formType andDictionary:(NSDictionary* _Nonnull) vars +{ + self = [self initWithType:type andFormType:formType]; + [self addEntriesFromDictionary:vars]; + return self; +} + +-(MLXMLNode*) setFieldWithDictionary:(NSDictionary*) field +{ + return [self setFieldWithDictionary:field atIndex:nil]; +} + +-(MLXMLNode*) setFieldWithDictionary:(NSDictionary*) field atIndex:(NSNumber* _Nullable) index +{ + MLXMLNode* fieldNode = [self setField:field[@"name"] withType:field[@"type"] andValue:[NSString stringWithFormat:@"%@", field[@"value"]] atIndex:index]; + if(field[@"options"]) + { + for(NSString* option in field[@"options"]) + if([field[@"options"][option] isEqualToString:option]) + [fieldNode addChildNode:[[MLXMLNode alloc] initWithElement:@"option" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"value" withAttributes:@{} andChildren:@[] andData:option] + ] andData:nil]]; + else + [fieldNode addChildNode:[[MLXMLNode alloc] initWithElement:@"option" withAttributes:@{@"label": field[@"options"][option]} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"value" withAttributes:@{} andChildren:@[] andData:option] + ] andData:nil]]; + } + if(field[@"description"]) + [fieldNode addChildNode:[[MLXMLNode alloc] initWithElement:@"desc" withAttributes:@{} andChildren:@[] andData:field[@"description"]]]; + if(field[@"required"] && [field[@"required"] boolValue]) + [fieldNode addChildNode:[[MLXMLNode alloc] initWithElement:@"required" withAttributes:@{} andChildren:@[] andData:nil]]; + return fieldNode; +} + +-(MLXMLNode*) setField:(NSString* _Nonnull) name withValue:(NSString* _Nonnull) value +{ + return [self setField:name withValue:value atIndex:nil]; +} + +-(MLXMLNode*) setField:(NSString* _Nonnull) name withValue:(NSString* _Nonnull) value atIndex:(NSNumber* _Nullable) index +{ + return [self setField:name withType:nil andValue:value atIndex:index]; +} + +-(MLXMLNode*) setField:(NSString* _Nonnull) name withType:(NSString* _Nullable) type andValue:(NSString* _Nonnull) value +{ + return [self setField:name withType:type andValue:value atIndex:nil]; +} + +-(MLXMLNode*) setField:(NSString* _Nonnull) name withType:(NSString* _Nullable) type andValue:(NSString* _Nonnull) value atIndex:(NSNumber* _Nullable) index +{ + MLXMLNode* operateAtNode = self; + if(index != nil) + { + NSArray* items = [self find:@"item"]; + operateAtNode = items[[index unsignedIntegerValue]]; + } + MLAssert(operateAtNode != nil, @"index out of bounds for multi-item form!", (@{ + @"index": index, + @"dataform": self, + })); + NSDictionary* attrs = type ? @{@"type": type, @"var": name} : @{@"var": name}; + [operateAtNode removeChildNode:[operateAtNode findFirst:@"field", name]]; + MLXMLNode* field = [operateAtNode addChildNode:[[MLXMLNode alloc] initWithElement:@"field" withAttributes:attrs andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"value" withAttributes:@{} andChildren:@[] andData:value] + ] andData:nil]]; + [self invalidateUpstreamCache]; //make sure future queries accurately reflect this change + return field; +} + +-(NSDictionary* _Nullable) getField:(NSString* _Nonnull) name +{ + return [self getField:name atIndex:nil]; +} + +-(NSDictionary* _Nullable) getField:(NSString* _Nonnull) name atIndex:(NSNumber* _Nullable) index +{ + MLXMLNode* fieldNode; + MLXMLNode* descriptionNode; + if(index != nil) + { + descriptionNode = [self findFirst:@"reported/field", name]; + NSArray* items = [self find:@"item"]; + fieldNode = [items[[index unsignedIntegerValue]] findFirst:@"field", name]; + } + else + { + fieldNode = [self findFirst:@"field", name]; + } + if(!fieldNode) + return nil; + if(descriptionNode == nil) + descriptionNode = fieldNode; + + NSMutableDictionary* options = [NSMutableDictionary new]; + for(MLXMLNode* option in [fieldNode find:@"option"]) + options[[NSString stringWithFormat:@"%@", [option findFirst:@"value#"]]] = [NSString stringWithFormat:@"%@", ([option check:@"/@label"] ? [option findFirst:@"/@label"] : [option findFirst:@"value#"])]; + NSMutableArray* allValues = [NSMutableArray new]; + for(id value in [fieldNode find:@"value#"]) + if(value != nil) //only safeguard, should never happen + [allValues addObject:[NSString stringWithFormat:@"%@", value]]; + if([descriptionNode check:@"/@type"]) + return @{ + @"name": [NSString stringWithFormat:@"%@", [fieldNode findFirst:@"/@var"]], + @"type": [NSString stringWithFormat:@"%@", [descriptionNode findFirst:@"/@type"]], + @"value": [NSString stringWithFormat:@"%@", [fieldNode findFirst:@"value#"]], + @"allValues": [allValues copy], //immutable copy + @"options": [options copy], //immutable copy + @"description": [NSString stringWithFormat:@"%@", [fieldNode findFirst:@"description#"]], + @"required": @([fieldNode check:@"required"]), + }; + return @{ + @"name": [NSString stringWithFormat:@"%@", [fieldNode findFirst:@"/@var"]], + @"value": [NSString stringWithFormat:@"%@", [fieldNode findFirst:@"value#"]], + @"allValues": [allValues copy], //immutable copy + @"options": [options copy], //immutable copy + @"description": [NSString stringWithFormat:@"%@", [fieldNode findFirst:@"description#"]], + @"required": @([fieldNode check:@"required"]), + }; +} + +-(void) removeField:(NSString* _Nonnull) name +{ + [self removeField:name atIndex:nil]; +} + +-(void) removeField:(NSString* _Nonnull) name atIndex:(NSNumber* _Nullable) index +{ + if(index != nil) + { + NSArray* items = [self find:@"item"]; + [self removeChildNode:[items[[index unsignedIntegerValue]] findFirst:@"field", name]]; + } + else + [self removeChildNode:[self findFirst:@"field", name]]; + [self invalidateUpstreamCache]; //make sure future queries accurately reflect this change +} + +-(void) setType:(NSString* _Nonnull) type +{ + self.attributes[@"type"] = type; + [self invalidateUpstreamCache]; //make sure future queries accurately reflect this change +} +-(NSString*) type +{ + return self.attributes[@"type"]; +} + +-(void) setFormType:(NSString* _Nonnull) formType +{ + [self setField:@"FORM_TYPE" withType:@"hidden" andValue:formType]; +} +-(NSString*) formType +{ + return self[@"FORM_TYPE"]; +} + +-(NSString* _Nullable) title +{ + return [self findFirst:@"title"]; +} +-(void) setTitle:(NSString* _Nullable) title +{ + if([self check:@"title"]) + ((MLXMLNode*)[self findFirst:@"title"]).data = title; + else + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"title" andData:title]]; +} + +-(NSString* _Nullable) instructions +{ + return [self findFirst:@"instructions"]; +} +-(void) setInstructions:(NSString* _Nullable) instructions +{ + if([self check:@"instructions"]) + ((MLXMLNode*)[self findFirst:@"instructions"]).data = instructions; + else + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"instructions" andData:instructions]]; +} + +-(id _Nullable) processDataFormQuery:(NSString*) query +{ + //parse query + NSMutableDictionary* parsedQuery = [NSMutableDictionary new]; + NSArray* matches = [dataFormQueryRegex matchesInString:query options:0 range:NSMakeRange(0, [query length])]; + if(![matches count]) + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Could not parse data form query!" userInfo:@{ + @"node": self, + @"query": query + }]; + NSTextCheckingResult* match = matches.firstObject; + NSRange formTypeRange = [match rangeAtIndex:2]; + NSRange typeRange = [match rangeAtIndex:3]; + NSRange indexRange = [match rangeAtIndex:5]; + NSRange extractionCommandRange = [match rangeAtIndex:6]; + if(formTypeRange.location != NSNotFound) + parsedQuery[@"formType"] = [query substringWithRange:formTypeRange]; + else + parsedQuery[@"formType"] = @"*"; + if(typeRange.location != NSNotFound) + parsedQuery[@"type"] = [query substringWithRange:typeRange]; + else + parsedQuery[@"type"] = @"*"; + if(indexRange.location != NSNotFound) + parsedQuery[@"index"] = [NSNumber numberWithUnsignedInteger:(NSUInteger)[[query substringWithRange:indexRange] longLongValue]]; + if(extractionCommandRange.location != NSNotFound) + { + NSString* extractionCommand = [query substringWithRange:extractionCommandRange]; + parsedQuery[@"extractionCommand"] = [extractionCommand substringToIndex:1]; + parsedQuery[@"var"] = [extractionCommand substringFromIndex:1]; + } + + //process query + if(!([@"*" isEqualToString:parsedQuery[@"formType"]] || (self.formType != nil && [self.formType isEqualToString:parsedQuery[@"formType"]]))) + return nil; + if(!([@"*" isEqualToString:parsedQuery[@"type"]] || (self.type != nil && [self.type isEqualToString:parsedQuery[@"type"]]))) + return nil; + + //handle non-item dataforms and queries with index out of bounds as nil result of our query + if(parsedQuery[@"index"] != nil) + { + if(![self check:@"item"]) + return nil; + if([self count] < [parsedQuery[@"index"] unsignedIntegerValue]) + return nil; + } + + if([parsedQuery[@"extractionCommand"] isEqualToString:@"@"]) + { + if(parsedQuery[@"index"] != nil) + return self[[parsedQuery[@"index"] unsignedIntegerValue]][parsedQuery[@"var"]]; + return self[parsedQuery[@"var"]]; + } + if([parsedQuery[@"extractionCommand"] isEqualToString:@"&"]) + { + if(parsedQuery[@"index"] != nil) + return [self getField:parsedQuery[@"var"] atIndex:parsedQuery[@"index"]]; + return [self getField:parsedQuery[@"var"]]; + } + return self; //we did not use any extraction command, but filtered by formType and type only +} + +-(NSString*) description +{ + NSMutableDictionary* dict = [NSMutableDictionary new]; + for(NSString* key in [self allKeys]) + dict[key] = [self getField:key]; + return [NSString stringWithFormat:@"XMPPDataForm%@%@ %@", + self.type ? [NSString stringWithFormat:@"[%@]", self.type] : @"", + self.formType ? [NSString stringWithFormat:@"{%@}", self.formType] : @"", + dict + ]; +} + +//*** NSArray interface below (only indexed subscript parts) + +-(id) objectAtIndexedSubscript:(NSInteger) idx +{ + NSArray* items = [self find:@"item"]; + if(items[idx] == nil) + return nil; + + NSMutableDictionary* retval = [NSMutableDictionary new]; + for(MLXMLNode* fieldNode in [items[idx] find:@"field"]) + retval[[NSString stringWithFormat:@"%@", [fieldNode findFirst:@"/@var"]]] = [NSString stringWithFormat:@"%@", [fieldNode findFirst:@"value#"]]; + return [retval copy]; //immutable copy +} + +-(void) setObject:(id _Nullable) obj atIndexedSubscript:(NSInteger) idx +{ + NSArray* items = [self find:@"item"]; + + //remove whole item if nil was given + if(obj == nil) + { + [self removeChildNode:items[idx]]; + [self invalidateUpstreamCache]; //make sure future queries accurately reflect this change + return; + } + + MLAssert([obj isKindOfClass:[NSDictionary class]], @"LHS number subscripts into a XMPPDataForm MUST have a NSDictionary on the RHS side!", (@{ + @"index": @(idx), + @"obj": nilWrapper(obj), + })); + + //remove all present fields nodes first + for(MLXMLNode* fieldNode in [items[idx] find:@"field"]) + [items[idx] removeChildNode:fieldNode]; + + //then create new field nodes as specified + NSDictionary* fields = (NSDictionary*)obj; + for(NSString* name in fields) + [items[idx] addChildNode:[[MLXMLNode alloc] initWithElement:@"field" withAttributes:@{@"var": name} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"value" withAttributes:@{} andChildren:@[] andData:fields[name]] + ] andData:nil]]; + [self invalidateUpstreamCache]; //make sure future queries accurately reflect this change +} + +//*** NSMutableDictionary interface below + +-(id _Nullable) objectForKeyedSubscript:(NSString* _Nonnull) key +{ + MLXMLNode* firstItem = [self findFirst:@"item"]; + if(firstItem == nil) + firstItem = self; + return [firstItem findFirst:@"field/value#", key]; +} + +-(void) setObject:(id _Nullable) obj forKeyedSubscript:(NSString*) key +{ + MLXMLNode* firstItem = [self findFirst:@"item"]; + if(firstItem == nil) + firstItem = self; + if(!obj) + { + [firstItem removeChildNode:[firstItem findFirst:@"field", key]]; + [self invalidateUpstreamCache]; //make sure future queries accurately reflect this change + return; + } + MLXMLNode* fieldNode; + fieldNode = [firstItem findFirst:@"field", key]; + if(!fieldNode) + { + [self setField:key withValue:[NSString stringWithFormat:@"%@", obj] atIndex:(firstItem != nil ? 0 : nil)]; + return; + } + MLXMLNode* valueNode = [fieldNode findFirst:@"value"]; + if(!valueNode) + [fieldNode addChildNode:[[MLXMLNode alloc] initWithElement:@"value" withAttributes:@{} andChildren:@[] andData:[NSString stringWithFormat:@"%@", obj]]]; + else + valueNode.data = [NSString stringWithFormat:@"%@", obj]; + [self invalidateUpstreamCache]; //make sure future queries accurately reflect this change +} + +//for multi-item forms it will only return the list of var names of the first item +//(as according to XEP-0004 all items should have the same set of field nodes --> this should contain all var names possible in any item) +-(NSArray*) allKeys +{ + MLXMLNode* firstItem = [self findFirst:@"item"]; + if(firstItem == nil) + firstItem = self; + return [firstItem find:@"field@var"]; +} + +-(NSArray*) allValues +{ + MLXMLNode* firstItem = [self findFirst:@"item"]; + if(firstItem == nil) + firstItem = self; + return [firstItem find:@"field/value#"]; +} + +//will return the count of items for a multi-item form and the count of vars otherwise +-(NSUInteger) count +{ + if([self check:@"item"]) + return [[self find:@"item"] count]; + return [[self allKeys] count]; +} + + +-(NSArray*) allKeysForObject:(id) anObject +{ + NSMutableArray* retval = [NSMutableArray new]; + MLXMLNode* firstItem = [self findFirst:@"item"]; + for(MLXMLNode* field in (firstItem != nil ? [firstItem find:@"field"] : [self find:@"field"])) + if([anObject isEqual:[field findFirst:@"value#"]]) + [retval addObject:[field findFirst:@"/@var"]]; + return retval; +} + +-(id) valueForKey:(NSString*) key +{ + MLXMLNode* firstItem = [self findFirst:@"item"]; + if(firstItem == nil) + firstItem = self; + return [firstItem findFirst:@"field/value#", key]; +} + +-(id) objectForKey:(NSString*) key +{ + return [self valueForKey:key]; +} + +-(void) removeObjectForKey:(NSString*) key +{ + [self removeField:key atIndex:([self check:@"item"] ? 0 : nil)]; +} + +-(void) removeAllObjects +{ + MLXMLNode* firstItem = [self findFirst:@"item"]; + if(firstItem == nil) + firstItem = self; + for(MLXMLNode* child in [firstItem find:@"field"]) + [firstItem removeChildNode:child]; + [self invalidateUpstreamCache]; //make sure future queries accurately reflect this change +} + +-(void) removeObjectsForKeys:(NSArray*) keyArray +{ + for(NSString* key in keyArray) + [self removeObjectForKey:key]; +} + +-(void) setObject:(NSString*) value forKey:(NSString*) key +{ + [self setField:key withValue:value atIndex:([self check:@"item"] ? 0 : nil)]; +} + +-(void) setValue:(NSString* _Nullable) value forKey:(NSString*) key +{ + if(!value) + [self removeObjectForKey:key]; + else + [self setObject:value forKey:key]; +} + +-(void) addEntriesFromDictionary:(NSDictionary*) vars +{ + for(NSString* key in vars) + { + if([vars[key] isKindOfClass:[NSDictionary class]]) + [self setFieldWithDictionary:vars[key] atIndex:([self check:@"item"] ? 0 : nil)]; + else + self[key] = vars[key]; + } +} + +-(void) setDictionary:(NSDictionary*) vars +{ + [self removeAllObjects]; + [self addEntriesFromDictionary:vars]; +} + +@end diff --git a/Monal/Classes/XMPPEdit.h b/Monal/Classes/XMPPEdit.h new file mode 100644 index 0000000..1ece9b4 --- /dev/null +++ b/Monal/Classes/XMPPEdit.h @@ -0,0 +1,26 @@ +// +// buddylist.h +// SworIM +// +// Created by Anurodh Pokharel on 11/21/08. +// Copyright 2008 __MyCompanyName__. All rights reserved. +// + +#import +#import "DataLayer.h" +@import SAMKeychain; +#import "MLXMPPManager.h" +#import "TOCropViewController.h" + +@interface XMPPEdit: UITableViewController { + IBOutlet UILabel *JIDLabel; +} + +@property (nonatomic, strong) NSNumber* accountID; +@property (nonatomic, strong) NSIndexPath* originIndex; + +-(IBAction) save:(id) sender; + +@end + + diff --git a/Monal/Classes/XMPPEdit.m b/Monal/Classes/XMPPEdit.m new file mode 100644 index 0000000..7c89a94 --- /dev/null +++ b/Monal/Classes/XMPPEdit.m @@ -0,0 +1,1136 @@ +// +// buddylist.m +// SworIM +// +// Created by Anurodh Pokharel on 11/21/08. +// Copyright 2008 __MyCompanyName__. All rights reserved. +// + +#import "XMPPEdit.h" +#import "xmpp.h" +#import "MBProgressHUD.h" +#import "MLButtonCell.h" +#import "MLImageManager.h" +#import "MLPasswordChangeTableViewController.h" +#import "MLSwitchCell.h" +#import "MLOMEMO.h" +#import "MLNotificationQueue.h" +#import "MonalAppDelegate.h" +#import "ActiveChatsViewController.h" +#import "Monal-Swift.h" + +@import MobileCoreServices; +@import AVFoundation; +@import UniformTypeIdentifiers.UTCoreTypes; +@import Intents; + +enum kSettingSection { + kSettingSectionAvatar, + kSettingSectionAccount, + kSettingSectionGeneral, + kSettingSectionAdvanced, + kSettingSectionEdit, + kSettingSectionCount +}; + +enum kSettingsAvatarRows { + SettingsAvatarRowsCnt +}; + +enum kSettingsAccountRows { + SettingsEnabledRow, + SettingsDisplayNameRow, + SettingsStatusMessageRow, + SettingsServerDetailsRow, + SettingsAccountRowsCnt +}; + +enum kSettingsGeneralRows { + SettingsChangePasswordRow, + SettingsOmemoKeysRow, + SettingsBlockedUsersRow, + SettingsGeneralRowsCnt +}; + +enum kSettingsAdvancedRows { + SettingsJidRow, + SettingsPasswordRow, + SettingsServerRow, + SettingsPortRow, + SettingsDirectTLSRow, + SettingsPlainActivatedRow, + SettingsResourceRow, + SettingsAdvancedRowsCnt +}; + +enum kSettingsEditRows { + SettingsClearHistoryRow, + SettingsRemoveAccountRow, + SettingsDeleteAccountRow, + SettingsEditRowsCnt +}; + +//this will hold all disabled rows of all enums (this is needed because the code below still references these rows) +enum DummySettingsRows { + DummySettingsRowsBegin = 100, +}; + + +@interface MLXMPPConnection () +@property (nonatomic) MLXMPPServer* server; +@property (nonatomic) MLXMPPIdentity* identity; +@end + +@interface XMPPEdit() +@property (nonatomic, strong) DataLayer* db; +@property (nonatomic, strong) NSMutableDictionary* sectionDictionary; + +@property (nonatomic, assign) BOOL editMode; +// Used for QR-Code scanning +@property (nonatomic, strong) NSString* jid; +@property (nonatomic, strong) NSString* password; + +@property (nonatomic, strong) NSString* accountType; + +@property (nonatomic, strong) NSString *rosterName; +@property (nonatomic, strong) NSString *statusMessage; +@property (nonatomic, strong) NSString *resource; +@property (nonatomic, strong) NSString *server; +@property (nonatomic, strong) NSString *port; + +@property (nonatomic, assign) BOOL enabled; +@property (nonatomic, assign) BOOL directTLS; + +@property (nonatomic, weak) UITextField *currentTextField; + +@property (nonatomic, strong) UIDocumentPickerViewController *imagePicker; + +@property (nonatomic, strong) UIImageView *userAvatarImageView; +@property (nonatomic, strong) UIImage *selectedAvatarImage; +@property (nonatomic) BOOL avatarChanged; +@property (nonatomic) BOOL rosterNameChanged; +@property (nonatomic) BOOL statusMessageChanged; +@property (nonatomic) BOOL detailsChanged; + +@property (nonatomic) BOOL plainActivated; + +@property (nonatomic) BOOL deactivateSave; +@end + +@implementation XMPPEdit + +-(void) hideKeyboard +{ + [self.currentTextField resignFirstResponder]; +} + +#pragma mark view lifecylce + +-(void) viewDidLoad +{ + self.deactivateSave = NO; + [super viewDidLoad]; + + [self.tableView registerNib:[UINib nibWithNibName:@"MLSwitchCell" + bundle:[NSBundle mainBundle]] + forCellReuseIdentifier:@"AccountCell"]; + + + [self.tableView registerNib:[UINib nibWithNibName:@"MLButtonCell" + bundle:[NSBundle mainBundle]] + forCellReuseIdentifier:@"ButtonCell"]; + + _db = [DataLayer sharedInstance]; + + if(self.accountID.intValue != -1) + self.editMode = YES; + + DDLogVerbose(@"got account number %@", self.accountID); + + UITapGestureRecognizer* gestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(hideKeyboard)]; // hides the kkyeboard when you tap outside the editing area + gestureRecognizer.cancelsTouchesInView = false; //this prevents it from blocking the button + [self.tableView addGestureRecognizer:gestureRecognizer]; + + self.avatarChanged = NO; + self.rosterNameChanged = NO; + self.statusMessageChanged = NO; + + //default strings used for edit and new mode + self.sectionDictionary = [NSMutableDictionary new]; + for(int entry = 0; entry < kSettingSectionCount; entry++) + switch(entry) + { + case kSettingSectionAvatar: + self.sectionDictionary[@(entry)] = @""; break; + case kSettingSectionAccount: + self.sectionDictionary[@(entry)] = @""; break; + case kSettingSectionGeneral: + self.sectionDictionary[@(entry)] = NSLocalizedString(@"General", @""); + break; + case kSettingSectionAdvanced: + self.sectionDictionary[@(entry)] = NSLocalizedString(@"Advanced Settings", @""); + break; + case kSettingSectionEdit: + self.sectionDictionary[@(entry)] = @""; + break; + default: + self.sectionDictionary[@(entry)] = @""; + break; + } + + if(self.originIndex && self.originIndex.section == 0) + { + //edit + DDLogVerbose(@"reading account number %@", self.accountID); + NSDictionary* settings = [_db detailsForAccount:self.accountID]; + MLAssert(settings != nil, @"Settings dict should never be nil here!"); + + self.jid = [NSString stringWithFormat:@"%@@%@", [settings objectForKey:@"username"], [settings objectForKey:@"domain"]]; + NSString* pass = [SAMKeychain passwordForService:kMonalKeychainName account:self.accountID.stringValue]; + + if(pass) + self.password = pass; + + self.server = [settings objectForKey:@"server"]; + + self.port = [NSString stringWithFormat:@"%@", [settings objectForKey:@"other_port"]]; + self.resource = [settings objectForKey:kResource]; + + self.enabled = [[settings objectForKey:kEnabled] boolValue]; + + self.directTLS = [[settings objectForKey:@"directTLS"] boolValue]; + + self.rosterName = [settings objectForKey:kRosterName]; + self.statusMessage = [settings objectForKey:@"statusMessage"]; + + self.plainActivated = [[settings objectForKey:kPlainActivated] boolValue]; + + //overwrite account section heading in edit mode + self.sectionDictionary[@(kSettingSectionAccount)] = [NSString stringWithFormat:NSLocalizedString(@"Account (%@)", @""), self.jid]; + } + else + { + self.title = NSLocalizedString(@"New Account", @""); + self.port = @"5222"; + self.resource = [HelperTools encodeRandomResource]; + self.directTLS = NO; + self.rosterName = @""; + self.statusMessage = @""; + self.enabled = YES; + self.plainActivated = NO; + + //overwrite account section heading in new mode + self.sectionDictionary[@(kSettingSectionAccount)] = NSLocalizedString(@"Account (new)", @""); + } +#if TARGET_OS_MACCATALYST + self.imagePicker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeImage]]; + self.imagePicker.allowsMultipleSelection = NO; + self.imagePicker.delegate = self; +#endif +} + +-(void) viewWillAppear:(BOOL) animated +{ + [super viewWillAppear:animated]; + DDLogVerbose(@"xmpp edit view will appear"); +} + +-(void) viewWillDisappear:(BOOL) animated +{ + [super viewWillDisappear:animated]; + DDLogVerbose(@"xmpp edit view will hide"); +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(void) alertWithTitle:(NSString*) title andMsg:(NSString*) msg +{ + dispatch_async(dispatch_get_main_queue(), ^{ + UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:msg 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]; + }); +} + +#pragma mark actions + +-(IBAction) save:(id) sender +{ + if(self.deactivateSave) + { + DDLogWarn(@"Save pressed but already deactivated!"); + return; + } + + NSError* error; + [self.currentTextField resignFirstResponder]; + + DDLogVerbose(@"Saving"); + + NSString* lowerJid = [self.jid.lowercaseString copy]; + NSString* domain; + NSString* user; + + if([lowerJid length] == 0) + { + [self alertWithTitle:NSLocalizedString(@"XMPP ID missing", @"") andMsg:NSLocalizedString(@"You have not entered your XMPP ID yet", @"")]; + return; + } + + if([lowerJid characterAtIndex:0] == '@') + { + //first char =@ means no username in jid + [self alertWithTitle:NSLocalizedString(@"Username missing", @"") andMsg:NSLocalizedString(@"Your entered XMPP ID is missing the username", @"")]; + return; + } + + //check if our keychain contains a password + if(self.enabled && self.password.length == 0) + { + [SAMKeychain passwordForService:kMonalKeychainName account:self.accountID.stringValue error:&error]; + if(error != nil) + { + DDLogError(@"Keychain error: %@", error); + self.enabled = NO; + [self.tableView reloadData]; + [self alertWithTitle:NSLocalizedString(@"Password missing", @"") andMsg:NSLocalizedString(@"Please enter a password below before activating this account.", @"")]; + return; + } + } + + NSArray* elements = [lowerJid componentsSeparatedByString:@"@"]; + + //if it is a JID + if([elements count] > 1) + { + user = [elements objectAtIndex:0]; + domain = [elements objectAtIndex:1]; + } + else + { + user = lowerJid; + domain = @""; + } + if([domain isEqualToString:@""]) + { + [self alertWithTitle:NSLocalizedString(@"Domain missing", @"") andMsg:NSLocalizedString(@"Your entered XMPP ID is missing the domain", @"")]; + return; + } + + NSMutableDictionary* dic = [NSMutableDictionary new]; + [dic setObject:[domain.lowercaseString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] forKey:kDomain]; + if(user) + [dic setObject:[user.lowercaseString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] forKey:kUsername]; + if(self.server) + [dic setObject:[self.server stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] forKey:kServer]; + if(self.port) + [dic setObject:self.port forKey:kPort]; + [dic setObject:self.resource forKey:kResource]; + [dic setObject:[NSNumber numberWithBool:self.enabled] forKey:kEnabled]; + [dic setObject:[NSNumber numberWithBool:self.directTLS] forKey:kDirectTLS]; + [dic setObject:self.accountID forKey:kAccountID]; + if(self.rosterName) + [dic setObject:[self.rosterName stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] forKey:kRosterName]; + if(self.statusMessage) + [dic setObject:[self.statusMessage stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] forKey:@"statusMessage"]; + + //conversations.im already supports sasl2 and scram ## TODO: use SCRAM preload list + [dic setObject:([domain.lowercaseString isEqualToString:@"conversations.im"] ? @NO : @(self.plainActivated)) forKey:kPlainActivated]; + + if(!self.editMode) + { + + if(([self.jid length] == 0) && + ([self.password length] == 0) + ) + { + //ignoring blank + } + else + { + BOOL accountExists = [[DataLayer sharedInstance] doesAccountExistUser:user andDomain:domain]; + if(!accountExists) + { + DDLogVerbose(@"Creating account: %@", dic); + NSNumber* accountID = [[DataLayer sharedInstance] addAccountWithDictionary:dic]; + if(accountID != nil) + { + self.accountID = accountID; + [SAMKeychain setAccessibilityType:kSecAttrAccessibleAfterFirstUnlock]; + [SAMKeychain setPassword:self.password forService:kMonalKeychainName account:self.accountID.stringValue]; + if(self.enabled) + { + DDLogVerbose(@"Now connecting newly created account: %@", self.accountID); + [[MLXMPPManager sharedInstance] connectAccount:self.accountID]; + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + [account publishStatusMessage:self.statusMessage]; + [account publishRosterName:self.rosterName]; + [account publishAvatar:self.selectedAvatarImage]; + } + else + { + DDLogVerbose(@"Making sure newly created account is not connected and deleting all SiriKit interactions: %@", self.accountID); + [[MLXMPPManager sharedInstance] disconnectAccount:self.accountID withExplicitLogout:YES]; + [HelperTools removeAllShareInteractionsForAccountID:self.accountID]; + } + //trigger view updates to make sure enabled/disabled account state propagates to all ui elements + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + [self showSuccessHUD]; + } + } + else + [self alertWithTitle:NSLocalizedString(@"Account Exists", @"") andMsg:NSLocalizedString(@"This account already exists in Monal.", @"")]; + } + } + else + { + [dic setObject:[NSNumber numberWithBool:NO] forKey:kNeedsPasswordMigration]; + DDLogVerbose(@"Updating existing account: %@", dic); + //disconnect account before disabling it in db, to avoid assertions when trying to create MLContact instances + //for the disabled account (for notifications etc.) + if(!self.enabled) + { + DDLogVerbose(@"Account is not enabled anymore, deleting all SiriKit interactions and making sure it's disconnected: %@", self.accountID); + [[MLXMPPManager sharedInstance] disconnectAccount:self.accountID withExplicitLogout:YES]; + [HelperTools removeAllShareInteractionsForAccountID:self.accountID]; + } + //this case makes sure we recreate a completely new account instance below (using our new settings) if the account details changed + else if(self.detailsChanged) + [[MLXMPPManager sharedInstance] disconnectAccount:self.accountID withExplicitLogout:NO]; + + DDLogVerbose(@"Now updating DB with account dict..."); + [[DataLayer sharedInstance] updateAccounWithDictionary:dic]; + if(self.password.length) + { + DDLogVerbose(@"Now setting password for account %@ in SAMKeychain...", self.accountID); + [[MLXMPPManager sharedInstance] updatePassword:self.password forAccount:self.accountID]; + } + if(self.enabled) + { + DDLogVerbose(@"Account is (still) enabled, connecting it: %@", self.accountID); + [[MLXMPPManager sharedInstance] connectAccount:self.accountID]; + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + if(self.statusMessageChanged) + [account publishStatusMessage:self.statusMessage]; + if(self.rosterNameChanged) + [account publishRosterName:self.rosterName]; + if(self.avatarChanged) + [account publishAvatar:self.selectedAvatarImage]; + } + //trigger view updates to make sure enabled/disabled account state propagates to all ui elements + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + [self showSuccessHUD]; + } +} + +-(void) showSuccessHUD +{ + dispatch_async(dispatch_get_main_queue(), ^{ + MBProgressHUD *hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + hud.mode = MBProgressHUDModeCustomView; + hud.removeFromSuperViewOnHide = YES; + hud.label.text = NSLocalizedString(@"Success", @""); + hud.detailsLabel.text = NSLocalizedString(@"The account has been saved", @""); + UIImage *image = [[UIImage imageNamed:@"success"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + hud.customView = [[UIImageView alloc] initWithImage:image]; + [hud hideAnimated:YES afterDelay:1.0f]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self dismissViewControllerAnimated:YES completion:nil]; + }); + }); +} + +- (IBAction) removeAccountClicked: (id) sender +{ + UIAlertController* questionAlert =[UIAlertController alertControllerWithTitle:NSLocalizedString(@"Delete Account", @"") message:NSLocalizedString(@"This will remove this account and the associated data from this device.", @"") preferredStyle:UIAlertControllerStyleActionSheet]; + UIAlertAction* noAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"No", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + //do nothing when "no" was pressed + }]; + UIAlertAction* yesAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Yes", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { + DDLogVerbose(@"Removing accountID %@", self.accountID); + self.deactivateSave = YES; + [[MLXMPPManager sharedInstance] removeAccountForAccountID:self.accountID]; + + MBProgressHUD* hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + hud.mode = MBProgressHUDModeCustomView; + hud.removeFromSuperViewOnHide = YES; + hud.label.text = NSLocalizedString(@"Success", @""); + hud.detailsLabel.text = NSLocalizedString(@"The account has been removed", @""); + UIImage* image = [[UIImage imageNamed:@"success"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + hud.customView = [[UIImageView alloc] initWithImage:image]; + [hud hideAnimated:YES afterDelay:1.0f]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self dismissViewControllerAnimated:YES completion:^{ + //we want to start fresh instead of doing a "password migration"-restore directly triggering an sms + [[HelperTools defaultsDB] removeObjectForKey:@"Quicksy_phoneNumber"]; + [[HelperTools defaultsDB] removeObjectForKey:@"Quicksy_country"]; + //make sure we show account creation view etc. after removing the last account + MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate]; + [appDelegate.activeChats segueToIntroScreensIfNeeded]; + }]; + }); + }]; + [questionAlert addAction:noAction]; + [questionAlert addAction:yesAction]; + + UIPopoverPresentationController* popPresenter = [questionAlert popoverPresentationController]; + popPresenter.sourceView = self.view; + + [self presentViewController:questionAlert animated:YES completion:nil]; +} + +-(IBAction) deleteAccountClicked:(id) sender +{ + xmpp* xmppAccount = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + if(xmppAccount.accountState < kStateBound) + { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error Removing Account", @"") + message:NSLocalizedString(@"Your account must be enabled and connected, to be removed from the server!", @"") 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; + } + + UIAlertController* questionAlert =[UIAlertController alertControllerWithTitle:NSLocalizedString(@"Delete Account", @"") message:NSLocalizedString(@"This will delete this account and the associated data from the server and this device. Data might still be retained on other devices, though.", @"") preferredStyle:UIAlertControllerStyleActionSheet]; + UIAlertAction* noAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"No", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + //do nothing when "no" was pressed + }]; + UIAlertAction* yesAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Yes", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { + DDLogVerbose(@"Deleting account on server: %@", xmppAccount); + self.deactivateSave = YES; + [xmppAccount removeFromServerWithCompletion:^(NSString* error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if(error != nil) + { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error Removing Account", @"") + message:error 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]; + } + else + { + MBProgressHUD* hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + hud.mode = MBProgressHUDModeCustomView; + hud.removeFromSuperViewOnHide = YES; + hud.label.text = NSLocalizedString(@"Success", @""); + hud.detailsLabel.text = NSLocalizedString(@"The account has been deleted", @""); + UIImage* image = [[UIImage imageNamed:@"success"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + hud.customView = [[UIImageView alloc] initWithImage:image]; + [hud hideAnimated:YES afterDelay:1.0f]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self dismissViewControllerAnimated:YES completion:^{ + //we want to start fresh instead of doing a "password migration"-restore directly triggering an sms + [[HelperTools defaultsDB] removeObjectForKey:@"Quicksy_phoneNumber"]; + [[HelperTools defaultsDB] removeObjectForKey:@"Quicksy_country"]; + //make sure we show account creation view etc. after removing the last account + MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate]; + [appDelegate.activeChats segueToIntroScreensIfNeeded]; + }]; + }); + } + }); + }]; + }]; + [questionAlert addAction:noAction]; + [questionAlert addAction:yesAction]; + + UIPopoverPresentationController* popPresenter = [questionAlert popoverPresentationController]; + popPresenter.sourceView = self.view; + + [self presentViewController:questionAlert animated:YES completion:nil]; +} + +- (IBAction) clearHistoryClicked: (id) sender +{ + DDLogVerbose(@"Deleting History"); + + UIAlertController *questionAlert =[UIAlertController alertControllerWithTitle:NSLocalizedString(@"Clear Chat History", @"") message:NSLocalizedString(@"This will clear the whole chat history of this account from this device.", @"") preferredStyle:UIAlertControllerStyleActionSheet]; + UIAlertAction *noAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"No", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + //do nothing when "no" was pressed + }]; + UIAlertAction *yesAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Yes", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { + + [self.db clearMessages:self.accountID]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + + MBProgressHUD *hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + hud.mode = MBProgressHUDModeCustomView; + hud.removeFromSuperViewOnHide=YES; + hud.label.text =NSLocalizedString(@"Success", @""); + hud.detailsLabel.text =NSLocalizedString(@"The chat history has been cleared", @""); + UIImage *image = [[UIImage imageNamed:@"success"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + hud.customView = [[UIImageView alloc] initWithImage:image]; + [hud hideAnimated:YES afterDelay:1.0f]; + }]; + + [questionAlert addAction:noAction]; + [questionAlert addAction:yesAction]; + + UIPopoverPresentationController* popPresenter = [questionAlert popoverPresentationController]; + popPresenter.sourceView = self.view; + + [self presentViewController:questionAlert animated:YES completion:nil]; + +} + +#pragma mark table view datasource methods + +-(CGFloat) tableView:(UITableView*) tableView heightForHeaderInSection:(NSInteger) section +{ + if (section == 0) + return 100; + else + return UITableViewAutomaticDimension; +} + +-(CGFloat) tableView:(UITableView*) tableView heightForRowAtIndexPath:(NSIndexPath*) indexPath +{ + return 40; +} + +-(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath +{ + DDLogVerbose(@"xmpp edit view section %ld, row %ld", indexPath.section, indexPath.row); + + MLSwitchCell* thecell = (MLSwitchCell*)[tableView dequeueReusableCellWithIdentifier:@"AccountCell"]; + [thecell clear]; + + // load cells from interface builder + if(indexPath.section == kSettingSectionAccount) + { + //the user + switch (indexPath.row) + { + case SettingsEnabledRow: { + [thecell initCell:NSLocalizedString(@"Enabled", @"") withToggle:self.enabled andTag:1]; + break; + } + case SettingsDisplayNameRow: { + [thecell initCell:NSLocalizedString(@"Display Name", @"") withTextField:self.rosterName andPlaceholder:@"" andTag:1]; + thecell.cellLabel.text = NSLocalizedString(@"Display Name", @""); + thecell.textInputField.keyboardType = UIKeyboardTypeAlphabet; + break; + } + case SettingsStatusMessageRow: { + [thecell initCell:NSLocalizedString(@"Status Message", @"") withTextField:self.statusMessage andPlaceholder:NSLocalizedString(@"Your status", @"") andTag:6]; + break; + } + case SettingsServerDetailsRow: { + [thecell initTapCell:NSLocalizedString(@"Protocol support of your server (XEPs)", @"")]; + thecell.accessoryType = UITableViewCellAccessoryDetailButton; + break; + } + } + } + else if(indexPath.section == kSettingSectionGeneral) + { + switch (indexPath.row) + { + case SettingsChangePasswordRow: { +#ifdef IS_QUICKSY + [thecell initTapCell:NSLocalizedString(@"Change/View Password", @"")]; +#else + [thecell initTapCell:NSLocalizedString(@"Change Password", @"")]; +#endif + thecell.cellLabel.text = NSLocalizedString(@"Change Password", @""); + break; + } + case SettingsOmemoKeysRow: { + [thecell initTapCell:NSLocalizedString(@"Encryption Keys (OMEMO)", @"")]; + break; + } + case SettingsBlockedUsersRow: { + [thecell initTapCell:NSLocalizedString(@"Blocked Users", @"")]; + break; + } + } + } + else if(indexPath.section == kSettingSectionAdvanced) + { + switch (indexPath.row) + { + case SettingsJidRow: { + if(self.editMode) + { + // don't allow jid editing + [thecell initCell:NSLocalizedString(@"XMPP ID", @"") withLabel:self.jid]; + } + else + { + // allow entering jid on account creation + [thecell initCell:NSLocalizedString(@"XMPP ID", @"") withTextField:self.jid andPlaceholder:NSLocalizedString(@"Enter your XMPP ID here", @"") andTag:2]; + thecell.textInputField.keyboardType = UIKeyboardTypeEmailAddress; + thecell.textInputField.autocorrectionType = UITextAutocorrectionTypeNo; + thecell.textInputField.autocapitalizationType = UITextAutocapitalizationTypeNone; + } + break; + } + case SettingsPasswordRow: { + [thecell initCell:NSLocalizedString(@"Password", @"") withTextField:self.password secureEntry:YES andPlaceholder:NSLocalizedString(@"Enter your password here", @"") andTag:3]; + break; + } + case SettingsServerRow: { + [thecell initCell:NSLocalizedString(@"Server", @"") withTextField:self.server andPlaceholder:NSLocalizedString(@"Optional Hardcoded Hostname", @"") andTag:4]; + break; + } + case SettingsPortRow: { + [thecell initCell:NSLocalizedString(@"Port", @"") withTextField:self.port andPlaceholder:NSLocalizedString(@"Optional Port", @"") andTag:5]; + break; + } + case SettingsDirectTLSRow: { + [thecell initCell:NSLocalizedString(@"Always use direct TLS, not STARTTLS", @"") withToggle:self.directTLS andTag:2]; + break; + } + case SettingsPlainActivatedRow: { + [thecell initCell:NSLocalizedString(@"Allow MITM-prone PLAIN authentication", @"") withToggle:self.plainActivated andTag:3]; + if(self.editMode) + [thecell.toggleSwitch setEnabled:NO]; + break; + } + case SettingsResourceRow: { + [thecell initCell:NSLocalizedString(@"Resource", @"") withLabel:self.resource]; + break; + } + } + } + else if (indexPath.section == kSettingSectionEdit && self.editMode == YES) + { + switch (indexPath.row) { + case SettingsClearHistoryRow: + { + MLButtonCell* buttonCell = (MLButtonCell*)[tableView dequeueReusableCellWithIdentifier:@"ButtonCell"]; + buttonCell.buttonText.text = NSLocalizedString(@"Clear Chat History", @""); + buttonCell.buttonText.textColor = [UIColor redColor]; + buttonCell.selectionStyle = UITableViewCellSelectionStyleNone; + buttonCell.tag = SettingsClearHistoryRow; + return buttonCell; + } + case SettingsRemoveAccountRow: + { + MLButtonCell* buttonCell = (MLButtonCell*)[tableView dequeueReusableCellWithIdentifier:@"ButtonCell"]; + buttonCell.buttonText.text = NSLocalizedString(@"Remove Account from this Device", @""); + buttonCell.buttonText.textColor = [UIColor redColor]; + buttonCell.selectionStyle = UITableViewCellSelectionStyleNone; + buttonCell.tag = SettingsRemoveAccountRow; + return buttonCell; + } + case SettingsDeleteAccountRow: + { + MLButtonCell* buttonCell = (MLButtonCell*)[tableView dequeueReusableCellWithIdentifier:@"ButtonCell"]; + buttonCell.buttonText.text = NSLocalizedString(@"Delete Account on Server", @""); + buttonCell.buttonText.textColor = [UIColor redColor]; + buttonCell.selectionStyle = UITableViewCellSelectionStyleNone; + buttonCell.tag = SettingsDeleteAccountRow; + return buttonCell; + } + } + } + thecell.textInputField.delegate = self; + if(thecell.textInputField.hidden == YES) + [thecell.toggleSwitch addTarget:self action:@selector(toggleSwitch:) forControlEvents:UIControlEventValueChanged]; + return thecell; +} + +-(NSInteger) numberOfSectionsInTableView:(UITableView*) tableView +{ + return kSettingSectionCount; +} + +-(UIView*) tableView:(UITableView*) tableView viewForHeaderInSection:(NSInteger) section +{ + if (section == 0) + { + UIView* avatarView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.tableView.frame.size.width, 100)]; + avatarView.backgroundColor = [UIColor clearColor]; + avatarView.userInteractionEnabled = YES; + + self.userAvatarImageView = [[UIImageView alloc] initWithFrame:CGRectMake((self.tableView.frame.size.width - 90)/2 , 25, 90, 90)]; + self.userAvatarImageView.layer.cornerRadius = self.userAvatarImageView.frame.size.height / 2; + self.userAvatarImageView.layer.borderWidth = 2.0f; + self.userAvatarImageView.layer.borderColor = ([UIColor clearColor]).CGColor; + self.userAvatarImageView.clipsToBounds = YES; + self.userAvatarImageView.userInteractionEnabled = YES; + + UITapGestureRecognizer* touchUserAvatarRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(getPhotoAction:)]; + [self.userAvatarImageView addGestureRecognizer:touchUserAvatarRecognizer]; + + if(self.editMode == YES && self.jid != nil && self.accountID.intValue >= 0) + [[MLImageManager sharedInstance] getIconForContact:[MLContact createContactFromJid:self.jid andAccountID:self.accountID] withCompletion:^(UIImage *image) { + [self.userAvatarImageView setImage:image]; + }]; + else + { + //use noicon image for account creation + [self.userAvatarImageView setImage:[MLImageManager circularImage:[UIImage imageNamed:@"noicon"]]]; + } + [avatarView addSubview:self.userAvatarImageView]; + + return avatarView; + } + else + { + NSString* sectionTitle = [self tableView:tableView titleForHeaderInSection:section]; + return [HelperTools MLCustomViewHeaderWithTitle:sectionTitle]; + } +} + +-(NSString*) tableView:(UITableView*) tableView titleForHeaderInSection:(NSInteger) section +{ + return self.sectionDictionary[@(section)]; +} + +-(NSInteger) tableView:(UITableView*) tableView numberOfRowsInSection:(NSInteger) section +{ + if(section == kSettingSectionAvatar) + return SettingsAvatarRowsCnt; + else if(section == kSettingSectionAccount) + return SettingsAccountRowsCnt; + else if(section == kSettingSectionGeneral && self.editMode) + return SettingsGeneralRowsCnt; + else if(section == kSettingSectionAdvanced) + return SettingsAdvancedRowsCnt; + else if(section == kSettingSectionEdit && self.editMode) + return SettingsEditRowsCnt; + else + return 0; +} + +#pragma mark - table view delegate +-(void) tableView:(UITableView*) tableView didSelectRowAtIndexPath:(NSIndexPath*) newIndexPath +{ + DDLogVerbose(@"selected log section %ld , row %ld", newIndexPath.section, newIndexPath.row); + + if(newIndexPath.section == kSettingSectionAccount) + { + switch(newIndexPath.row) + { + case SettingsServerDetailsRow: { + xmpp* xmppAccount = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + UIViewController* serverDetailsView = [[SwiftuiInterface new] makeServerDetailsViewFor:xmppAccount]; + [self showDetailViewController:serverDetailsView sender:self]; + break; + } + } + } + else if(newIndexPath.section == kSettingSectionGeneral) + { + switch(newIndexPath.row) + { + case SettingsChangePasswordRow: + [self performSegueWithIdentifier:@"showPassChange" sender:self]; + break; + case SettingsOmemoKeysRow: { + UIViewController* ownOmemoKeysView; + xmpp* xmppAccount = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + if(self.jid == nil || self.accountID == nil || xmppAccount == nil) + { + ownOmemoKeysView = [[SwiftuiInterface new] makeOwnOmemoKeyView:nil]; + } else { + MLContact* ownContact = [MLContact createContactFromJid:self.jid andAccountID:self.accountID]; + ownOmemoKeysView = [[SwiftuiInterface new] makeOwnOmemoKeyView:ownContact]; + } + [self showDetailViewController:ownOmemoKeysView sender:self]; + break; + } + case SettingsBlockedUsersRow: { + xmpp* xmppAccount = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + if(xmppAccount != nil) + { + UIViewController* blockedUsersView = [[SwiftuiInterface new] makeBlockedUsersViewFor:xmppAccount]; + [self showDetailViewController:blockedUsersView sender:self]; + } + break; + } + } + } + else if(newIndexPath.section == kSettingSectionAdvanced) + { + // nothing to do here + } + else if(newIndexPath.section == kSettingSectionEdit) + { + switch(newIndexPath.row) + { + case SettingsClearHistoryRow: + [self clearHistoryClicked:[tableView cellForRowAtIndexPath:newIndexPath]]; + break; + case SettingsRemoveAccountRow: + [self removeAccountClicked:[tableView cellForRowAtIndexPath:newIndexPath]]; + break; + case SettingsDeleteAccountRow: + [self deleteAccountClicked:[tableView cellForRowAtIndexPath:newIndexPath]]; + break; + } + } +} + +-(void) tableView:(UITableView*) tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath*) indexPath +{ + if(indexPath.section == kSettingSectionAccount) + { + switch(indexPath.row) + { + case SettingsServerDetailsRow: { + xmpp* xmppAccount = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + UIViewController* serverDetailsView = [[SwiftuiInterface new] makeServerDetailsViewFor:xmppAccount]; + [self showDetailViewController:serverDetailsView sender:self]; + break; + } + } + } +} + + +#pragma mark - segeue + +-(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender +{ + if([segue.identifier isEqualToString:@"showPassChange"]) + { + if(self.jid && self.accountID) + { + MLPasswordChangeTableViewController* pwchange = (MLPasswordChangeTableViewController*)segue.destinationViewController; + pwchange.xmppAccount = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + } + } +} + +#pragma mark - text input fielddelegate + +-(void) textFieldDidBeginEditing:(UITextField*) textField +{ + self.currentTextField = textField; + if(textField.tag == 1) //user input field + { + if(textField.text.length > 0) { + UITextPosition* startPos = textField.beginningOfDocument; + UITextRange* newRange = [textField textRangeFromPosition:startPos toPosition:startPos]; + + // Set new range + [textField setSelectedTextRange:newRange]; + } + } +} + +-(void) textFieldDidEndEditing:(UITextField*) textField +{ + switch (textField.tag) { + case 1: { + self.rosterName = textField.text; + self.rosterNameChanged = YES; + break; + } + case 2: { + self.jid = textField.text; + self.detailsChanged = YES; + break; + } + case 3: { + self.password = textField.text; + self.detailsChanged = YES; + break; + } + case 4: { + self.server = textField.text; + self.detailsChanged = YES; + break; + } + case 5: { + self.port = textField.text; + self.detailsChanged = YES; + break; + } + case 6: { + self.statusMessage = textField.text; + self.statusMessageChanged = YES; + break; + } + default: + break; + } +} + +-(BOOL) textFieldShouldReturn:(UITextField*) textField +{ + [textField resignFirstResponder]; + return true; +} + + +-(void) toggleSwitch:(id) sender +{ + UISwitch* toggle = (UISwitch*) sender; + + switch (toggle.tag) { + case 1: { + self.enabled = toggle.on; + break; + } + case 2: { + self.directTLS = toggle.on; + self.detailsChanged = YES; + break; + } + case 3: { + self.plainActivated = toggle.on; + self.detailsChanged = YES; + if(self.plainActivated) + { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Warning", @"") + message:NSLocalizedString(@"If you turn this on, you will no longer be safe from man-in-the-middle attacks. Such attacks enable the adversary to manipulate your incoming and outgoing messages, add their own OMEMO keys, change your account details and even know or change your password!\n\nYou should rather switch to another server than turning this on.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Understood", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; + } + break; + } + } +} + +#pragma mark - doc picker +-(void) pickImgFile:(id) sender +{ + [self presentViewController:self.imagePicker animated:YES completion:nil]; + return; +} + +-(void) documentPicker:(UIDocumentPickerViewController*) controller didPickDocumentsAtURLs:(NSArray*) urls +{ + NSFileCoordinator* coordinator = [NSFileCoordinator new]; + [coordinator coordinateReadingItemAtURL:urls.firstObject options:NSFileCoordinatorReadingForUploading error:nil byAccessor:^(NSURL* _Nonnull newURL) { + NSData* data =[NSData dataWithContentsOfURL:newURL]; + UIImage* pickImg = [UIImage imageWithData:data]; + [self useAvatarImage:pickImg]; + }]; +} + +-(void) getPhotoAction:(UIGestureRecognizer*) recognizer +{ + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:self.accountID]; + if (!account) + return; + UIAlertController* actionControll = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Select Action", @"") + message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + +#if TARGET_OS_MACCATALYST + [self pickImgFile:nil]; +#else + UIImagePickerController* imagePicker = [UIImagePickerController new]; + imagePicker.delegate = self; + + UIAlertAction* cameraAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Camera", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { + imagePicker.sourceType = UIImagePickerControllerSourceTypeCamera; + [self presentViewController:imagePicker animated:YES completion:nil]; + }]; + + UIAlertAction* photosAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Photos", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { + imagePicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) { + if(granted) + { + dispatch_async(dispatch_get_main_queue(), ^{ + [self presentViewController:imagePicker animated:YES completion:nil]; + }); + } + }]; + }]; + + // Set image + [cameraAction setValue:[[UIImage systemImageNamed:@"camera"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; + [photosAction setValue:[[UIImage systemImageNamed:@"photo"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; + [actionControll addAction:cameraAction]; + [actionControll addAction:photosAction]; +#endif + + // Set image + [actionControll addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { + [actionControll dismissViewControllerAnimated:YES completion:nil]; + }]]; + + actionControll.popoverPresentationController.sourceView = self.userAvatarImageView; + [self presentViewController:actionControll animated:YES completion:nil]; +} + +-(void) imagePickerController:(UIImagePickerController*) picker didFinishPickingMediaWithInfo:(NSDictionary*) info +{ + NSString* mediaType = info[UIImagePickerControllerMediaType]; + if([mediaType isEqualToString:UTTypeImage.identifier]) { + UIImage* selectedImage = info[UIImagePickerControllerEditedImage]; + if(!selectedImage) selectedImage = info[UIImagePickerControllerOriginalImage]; + + TOCropViewController* cropViewController = [[TOCropViewController alloc] initWithImage:selectedImage]; + cropViewController.delegate = self; + cropViewController.transitioningDelegate = nil; + //set square aspect ratio and don't let the user change that (this is a avatar which should be square for maximum compatibility with other clients) + cropViewController.aspectRatioPreset = TOCropViewControllerAspectRatioPresetSquare; + cropViewController.aspectRatioLockEnabled = YES; + cropViewController.aspectRatioPickerButtonHidden = YES; + + UINavigationController* cropRootController = [[UINavigationController alloc] initWithRootViewController:cropViewController]; + [picker dismissViewControllerAnimated:YES completion:^{ + [self presentViewController:cropRootController animated:YES completion:nil]; + }]; + } + else + [picker dismissViewControllerAnimated:YES completion:nil]; +} + +-(void) imagePickerControllerDidCancel:(UIImagePickerController*) picker +{ + [picker dismissViewControllerAnimated:YES completion:nil]; +} + +-(void) useAvatarImage:(UIImage*) selectedImg +{ + /* + //small sample image + UIGraphicsImageRenderer* renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(200, 200)]; + selectedImg = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull context) { + [[UIColor darkGrayColor] setStroke]; + [context strokeRect:renderer.format.bounds]; + [[UIColor colorWithRed:158/255.0 green:215/255.0 blue:245/255.0 alpha:1] setFill]; + [context fillRect:CGRectMake(1, 1, 140, 140)]; + }]; + */ + + //check if conversion can be done and display error if not + if(selectedImg && UIImageJPEGRepresentation(selectedImg, 1.0)) + { + self.selectedAvatarImage = selectedImg; + [self.userAvatarImageView setImage:self.selectedAvatarImage]; + self.avatarChanged = YES; + } + else + { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", @"") + message:NSLocalizedString(@"Can't convert the image to jpeg format.", @"") 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]; + } +} + + +#pragma mark -- TOCropViewController delagate + +-(void) cropViewController:(nonnull TOCropViewController*) cropViewController didCropToImage:(UIImage* _Nonnull) image withRect:(CGRect) cropRect angle:(NSInteger) angle +{ + [self useAvatarImage:image]; + [self dismissViewControllerAnimated:YES completion:nil]; +} + +@end diff --git a/Monal/Classes/XMPPIQ.h b/Monal/Classes/XMPPIQ.h new file mode 100644 index 0000000..569ff59 --- /dev/null +++ b/Monal/Classes/XMPPIQ.h @@ -0,0 +1,145 @@ +// +// XMPPIQ.h +// Monal +// +// Created by Anurodh Pokharel on 6/30/13. +// +// + +#import "XMPPStanza.h" +#import "MLContact.h" + +@class XMPPDataForm; + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSString* const kiqGetType; +FOUNDATION_EXPORT NSString* const kiqSetType; +FOUNDATION_EXPORT NSString* const kiqResultType; +FOUNDATION_EXPORT NSString* const kiqErrorType; + +@interface XMPPIQ : XMPPStanza + +-(id) initWithType:(NSString*) iqType; +-(id) initWithType:(NSString*) iqType to:(NSString*) to; +-(id) initAsResponseTo:(XMPPIQ*) iq; +-(id) initAsErrorTo:(XMPPIQ*) iq; + +-(void) setPushEnableWithNode:(NSString*) node onAppserver:(NSString*) jid; +-(void) setPushDisable:(NSString*) node onPushServer:(NSString*) pushServer; + +/** + Makes an iq to bind with a resouce. Passing nil will set no resource. + */ +-(void) setBindWithResource:(NSString*) resource; + +/** + set to attribute + */ +-(void) setiqTo:(NSString*) to; + +/** + makes iq of ping type + */ +-(void) setPing; + +-(void) setPurgeOfflineStorage; + +/** + gets MAM prefernces + */ +-(void) mamArchivePref; + +/* + updates MAM pref + @param pref can only be aways, never or roster + */ +-(void) updateMamArchivePrefDefault:(NSString *) pref; + +-(void) setMAMQueryLatestMessagesForJid:(NSString* _Nullable) jid before:(NSString* _Nullable) uid; +-(void) setMAMQueryAfter:(NSString*) uid; +-(void) setMAMQueryAfterTimestamp:(NSDate* _Nullable) timestamp; +-(void) setMAMQueryForLatestId; + +-(void) setMucListQueryFor:(NSString*) listType; + +#pragma mark disco + +/** + makes a disco info response for the server. + @param node param passed is the xmpp node attribute that came in with the iq get + */ +-(void) setDiscoInfoWithFeatures:(NSSet*) features identity:(MLXMLNode*) identity andNode:(NSString*) node; + +/** + sets up a disco info query node + */ +-(void) setDiscoInfoNode; + +/** + sets up a disco info query node + */ +-(void) setDiscoItemNode; +-(void) setAdhocDiscoNode; + +#pragma mark roster + +/** +gets Entity SoftWare Version + */ +-(void) getEntitySoftwareVersionInfo; + +/** +removes a contact from the roster + */ +-(void) setRemoveFromRoster:(MLContact*) contact; + +-(void) setUpdateRosterItem:(MLContact*) contact withName:(NSString*) name; + +/** + Requests a full roster from the server. A null version will not set the ver attribute + */ +-(void) setRosterRequest:(NSString* _Nullable) version; + +/** + makes iq with version element + */ +-(void) setVersion; + +/** + sets up an iq that requests a http upload slot + */ +-(void) httpUploadforFile:(NSString*) file ofSize:(NSNumber*) filesize andContentType:(NSString*) contentType; + +#pragma mark MUC + +/** + create instant room + */ +-(void) setInstantRoom; + +-(void) setVcardAvatarWithData:(NSData*) imageData andType:(NSString*) imageType; +-(void) setRemoveVcardAvatar; +-(void) setVcardQuery; + +#pragma mark - account + +-(void) submitRegToken:(NSString*) token; +-(void) getRegistrationFields; +-(void) registerUser:(NSString*) user withPassword:(NSString*) newPass captcha:(NSString* _Nullable) captcha andHiddenFields:(NSDictionary* _Nullable) hiddenFields; +-(void) changePasswordForUser:(NSString*) user newPassword:(NSString*) newPass; + +-(void) setBlocked:(BOOL) blocked forJid:(NSString*) blockedJid; +-(void) requestBlockList; + +-(void) setMucAdminQueryWithAffiliation:(NSString*) affiliation forJid:(NSString*) jid; +-(void) setGetRoomConfig; +-(void) setRoomConfig:(XMPPDataForm*) configForm; + +#ifdef IS_QUICKSY +-(void) setQuicksyPhoneBook:(NSArray*) numbers; +#endif + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/XMPPIQ.m b/Monal/Classes/XMPPIQ.m new file mode 100644 index 0000000..09a1845 --- /dev/null +++ b/Monal/Classes/XMPPIQ.m @@ -0,0 +1,466 @@ +// +// XMPPIQ.m +// Monal +// +// Created by Anurodh Pokharel on 6/30/13. +// +// + +#import "XMPPIQ.h" +#import "XMPPDataForm.h" +#import "HelperTools.h" +#import "SignalPreKey.h" +#import "MLContact.h" + +@class MLContact; + +NSString* const kiqGetType = @"get"; +NSString* const kiqSetType = @"set"; +NSString* const kiqResultType = @"result"; +NSString* const kiqErrorType = @"error"; + +@implementation XMPPIQ + +-(id) initInternalWithId:(NSString*) iqid andType:(NSString*) iqType +{ + self = [super initWithElement:@"iq"]; + [self setXMLNS:@"jabber:client"]; + self.id = iqid; + if(iqType) + self.attributes[@"type"] = iqType; + return self; +} + +-(id) initWithType:(NSString*) iqType +{ + return [self initInternalWithId:[[NSUUID UUID] UUIDString] andType:iqType]; +} + +-(id) initWithType:(NSString*) iqType to:(NSString*) to +{ + self = [self initWithType:iqType]; + if(to) + [self setiqTo:to]; + return self; +} + +-(id) initAsResponseTo:(XMPPIQ*) iq +{ + self = [self initInternalWithId:[iq findFirst:@"/@id"] andType:kiqResultType]; + if(iq.from) + [self setiqTo:iq.from]; + return self; +} + +-(id) initAsErrorTo:(XMPPIQ*) iq +{ + self = [self initInternalWithId:[iq findFirst:@"/@id"] andType:kiqErrorType]; + if(iq.from) + [self setiqTo:iq.from]; + return self; +} + +#pragma mark iq set + +// direct push registration at xmpp server without registration at appserver +-(void) setPushEnableWithNode:(NSString*) node onAppserver:(NSString*) jid +{ + NSMutableString* pushModule = [NSMutableString new]; +#ifdef IS_ALPHA + [pushModule appendString:@"monalAlpha"]; +#else //IS_ALPHA +#if TARGET_OS_MACCATALYST && defined(IS_QUICKSY) + [pushModule appendString:@"quicksyProdCatalyst"]; +#elif TARGET_OS_MACCATALYST + [pushModule appendString:@"monalProdCatalyst"]; +#elif defined(IS_QUICKSY) + [pushModule appendString:@"quicksyProdiOS"]; +#else + [pushModule appendString:@"monalProdiOS"]; +#endif +#endif + + if([[HelperTools defaultsDB] boolForKey:@"isSandboxAPNS"]) + { + [pushModule appendString:@"-sandbox"]; + DDLogInfo(@"Detected APNS sandbox, using sandbox push module: %@", pushModule); + } + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"enable" andNamespace:@"urn:xmpp:push:0" withAttributes:@{ + @"jid": jid, + @"node": node + } andChildren:@[ + [[XMPPDataForm alloc] initWithType:@"submit" formType:@"http://jabber.org/protocol/pubsub#publish-options" andDictionary:@{ + @"pushModule": pushModule + }] + ] andData:nil]]; +} + +-(void) setPushDisable:(NSString*) node onPushServer:(NSString*) pushServer +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"disable" andNamespace:@"urn:xmpp:push:0" withAttributes:@{ + @"jid": pushServer, + @"node": node + } andChildren:@[] andData:nil]]; +} + +-(void) setBindWithResource:(NSString*) resource +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"bind" andNamespace:@"urn:ietf:params:xml:ns:xmpp-bind" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"resource" andData:resource] + ] andData:nil]]; +} + +-(void) setMucListQueryFor:(NSString*) listType +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/muc#admin" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{@"affiliation": listType} andChildren:@[] andData:nil] + ] andData:nil]]; +} + +-(void) setAdhocDiscoNode +{ + MLXMLNode* queryNode = [[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/disco#items" withAttributes:@{ + @"node": @"http://jabber.org/protocol/commands", + } andChildren:@[] andData:nil]; + [self addChildNode:queryNode]; +} + +-(void) setDiscoInfoNode +{ + MLXMLNode* queryNode = [[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/disco#info"]; + [self addChildNode:queryNode]; +} + +-(void) setDiscoItemNode +{ + MLXMLNode* queryNode = [[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/disco#items"]; + [self addChildNode:queryNode]; +} + +-(void) setDiscoInfoWithFeatures:(NSSet*) features identity:(MLXMLNode*) identity andNode:(NSString*) node +{ + MLXMLNode* queryNode = [[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/disco#info"]; + if(node) + [queryNode.attributes setObject:node forKey:@"node"]; + + for(NSString* feature in features) + { + MLXMLNode* featureNode = [[MLXMLNode alloc] initWithElement:@"feature"]; + featureNode.attributes[@"var"] = feature; + [queryNode addChildNode:featureNode]; + } + + [queryNode addChildNode:identity]; + + [self addChildNode:queryNode]; +} + +-(void) setiqTo:(NSString*) to +{ + if(to) + self.to = to; +} + +-(void) setPing +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"ping" andNamespace:@"urn:xmpp:ping"]]; +} + +-(void) setPurgeOfflineStorage +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"offline" andNamespace:@"http://jabber.org/protocol/offline" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"purge"] + ] andData:nil]]; +} + +#pragma mark - MAM + +-(void) mamArchivePref +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"prefs" andNamespace:@"urn:xmpp:mam:2"]]; +} + +-(void) updateMamArchivePrefDefault:(NSString *) pref +{ + /** + pref is aways, never or roster + */ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"prefs" andNamespace:@"urn:xmpp:mam:2" withAttributes:@{@"default": pref} andChildren:@[] andData:nil]]; +} + +-(void) setMAMQueryLatestMessagesForJid:(NSString* _Nullable) jid before:(NSString* _Nullable) uid +{ + //set iq id to mam query id + self.id = [NSString stringWithFormat:@"MLhistory:%@", [[NSUUID UUID] UUIDString]]; + XMPPDataForm* form = [[XMPPDataForm alloc] initWithType:@"submit" andFormType:@"urn:xmpp:mam:2"]; + if(jid) + form[@"with"] = jid; + MLXMLNode* queryNode = [[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"urn:xmpp:mam:2" withAttributes:@{ + @"queryid": self.id + } andChildren:@[ + form, + [[MLXMLNode alloc] initWithElement:@"set" andNamespace:@"http://jabber.org/protocol/rsm" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"max" andData:@"50"], + [[MLXMLNode alloc] initWithElement:@"before" andData:uid] + ] andData:nil] + ] andData:nil]; + [self addChildNode:queryNode]; +} + +-(void) setMAMQueryForLatestId +{ + //set iq id to mam query id + self.id = [NSString stringWithFormat:@"MLignore:%@", [[NSUUID UUID] UUIDString]]; + MLXMLNode* queryNode = [[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"urn:xmpp:mam:2" withAttributes:@{ + @"queryid": self.id + } andChildren:@[ + [[XMPPDataForm alloc] initWithType:@"submit" formType:@"urn:xmpp:mam:2" andDictionary:@{ + @"end": [HelperTools generateDateTimeString:[NSDate date]] + }], + [[MLXMLNode alloc] initWithElement:@"set" andNamespace:@"http://jabber.org/protocol/rsm" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"max" andData:@"1"], + [[MLXMLNode alloc] initWithElement:@"before"] + ] andData:nil] + ] andData:nil]; + [self addChildNode:queryNode]; +} + +-(void) setMAMQueryAfter:(NSString*) uid +{ + //set iq id to mam query id + self.id = [NSString stringWithFormat:@"MLcatchup:%@", [[NSUUID UUID] UUIDString]]; + MLXMLNode* queryNode = [[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"urn:xmpp:mam:2" withAttributes:@{ + @"queryid": self.id + } andChildren:@[ + [[XMPPDataForm alloc] initWithType:@"submit" andFormType:@"urn:xmpp:mam:2"], + [[MLXMLNode alloc] initWithElement:@"set" andNamespace:@"http://jabber.org/protocol/rsm" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"max" andData:@"50"], + [[MLXMLNode alloc] initWithElement:@"after" andData:uid] + ] andData:nil] + ] andData:nil]; + [self addChildNode:queryNode]; +} + +-(void) setMAMQueryAfterTimestamp:(NSDate* _Nullable) timestamp +{ + //set iq id to mam query id + self.id = [NSString stringWithFormat:@"MLcatchup:%@", [[NSUUID UUID] UUIDString]]; + MLXMLNode* queryNode = [[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"urn:xmpp:mam:2" withAttributes:@{ + @"queryid": self.id + } andChildren:@[ + //query whole archive if the timestamp is nil (e.g. we never received any message contained in this archive + //nor did our stanzaid priming archive query succeed) + (timestamp==nil ? + [[XMPPDataForm alloc] initWithType:@"submit" andFormType:@"urn:xmpp:mam:2"] + : + [[XMPPDataForm alloc] initWithType:@"submit" formType:@"urn:xmpp:mam:2" andDictionary:@{ + @"start": [HelperTools generateDateTimeString:timestamp] + }] + ), + [[MLXMLNode alloc] initWithElement:@"set" andNamespace:@"http://jabber.org/protocol/rsm" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"max" andData:@"50"] + ] andData:nil] + ] andData:nil]; + [self addChildNode:queryNode]; + +#ifdef IS_ALPHA + if(timestamp == nil) + showXMLErrorOnAlpha(nil, self, @"setMAMQueryAfterTimestamp: called with nil timestamp!"); +#endif +} + +-(void) setRemoveFromRoster:(MLContact*) contact +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"jabber:iq:roster" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{ + @"jid": contact.contactJid, + @"subscription": @"remove" + } andChildren:@[] andData:nil] + ] andData:nil]]; +} + +-(void) setUpdateRosterItem:(MLContact* _Nonnull) contact withName:(NSString* _Nonnull) name +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"jabber:iq:roster" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{ + @"jid": contact.contactJid, + @"name": name, + } andChildren:@[] andData:nil] + ] andData:nil]]; +} + +-(void) setRosterRequest:(NSString*) version +{ + NSDictionary* attrs = @{}; + if(version && ![version isEqual:@""]) + attrs = @{@"ver": version}; + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"jabber:iq:roster" withAttributes:attrs andChildren:@[] andData:nil]]; +} + +-(void) setVersion +{ + NSOperatingSystemVersion osVersion = [[NSProcessInfo processInfo] operatingSystemVersion]; + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"jabber:iq:version" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"name" andData:@"Monal"], +#if TARGET_OS_MACCATALYST + [[MLXMLNode alloc] initWithElement:@"os" andData:[NSString stringWithFormat:@"macOS %lu", osVersion.majorVersion]], +#else + [[MLXMLNode alloc] initWithElement:@"os" andData:[NSString stringWithFormat:@"iOS %lu", osVersion.majorVersion]], +#endif + [[MLXMLNode alloc] initWithElement:@"version" andData:[HelperTools appBuildVersionInfoFor:MLVersionTypeIQ]] + ] andData:nil]]; +} + +-(void) setBlocked:(BOOL) blocked forJid:(NSString* _Nonnull) blockedJid +{ + MLXMLNode* blockNode = [[MLXMLNode alloc] initWithElement:(blocked ? @"block" : @"unblock") andNamespace:@"urn:xmpp:blocking"]; + + MLXMLNode* itemNode = [[MLXMLNode alloc] initWithElement:@"item"]; + [itemNode.attributes setObject:blockedJid forKey:@"jid"]; + [blockNode addChildNode:itemNode]; + + [self addChildNode:blockNode]; +} + +-(void) requestBlockList +{ + MLXMLNode* blockNode = [[MLXMLNode alloc] initWithElement:@"blocklist" andNamespace:@"urn:xmpp:blocking"]; + [self addChildNode:blockNode]; +} + +-(void) httpUploadforFile:(NSString *) file ofSize:(NSNumber *) filesize andContentType:(NSString *) contentType +{ + MLXMLNode* requestNode = [[MLXMLNode alloc] initWithElement:@"request" andNamespace:@"urn:xmpp:http:upload:0"]; + requestNode.attributes[@"filename"] = file; + requestNode.attributes[@"size"] = [NSString stringWithFormat:@"%@", filesize]; + requestNode.attributes[@"content-type"] = contentType; + [self addChildNode:requestNode]; +} + + +#pragma mark iq get + +-(void) getEntitySoftwareVersionInfo +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"jabber:iq:version"]]; +} + +#pragma mark MUC + +-(void) setGetRoomConfig +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/muc#owner"]]; +} + +-(void) setRoomConfig:(XMPPDataForm*) configForm +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/muc#owner" withAttributes:@{} andChildren:@[configForm] andData:nil]]; +} + +-(void) setInstantRoom +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/muc#owner" withAttributes:@{} andChildren:@[ + [[XMPPDataForm alloc] initWithType:@"submit" andFormType:@"http://jabber.org/protocol/muc#roomconfig"] + ] andData:nil]]; +} + +-(void) setRemoveVcardAvatar +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"vCard" andNamespace:@"vcard-temp" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"PHOTO" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"PHOTO" andData:nil], + [[MLXMLNode alloc] initWithElement:@"BINVAL" andData:nil], + ] andData:nil] + ] andData:nil]]; +} + +-(void) setVcardAvatarWithData:(NSData*) imageData andType:(NSString*) imageType +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"vCard" andNamespace:@"vcard-temp" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"PHOTO" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"PHOTO" andData:imageType], + [[MLXMLNode alloc] initWithElement:@"BINVAL" andData:[HelperTools encodeBase64WithData:imageData]], + ] andData:nil] + ] andData:nil]]; +} + +-(void) setVcardQuery +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"vCard" andNamespace:@"vcard-temp"]]; +} + +#pragma mark - Account Management + +-(void) submitRegToken:(NSString*) token +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"preauth" andNamespace:@"urn:xmpp:pars:0" withAttributes:@{ + @"token": token + } andChildren:@[] andData:nil]]; +} + +-(void) getRegistrationFields +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:kRegisterNameSpace]]; +} + +/* + This is really hardcoded for yax.im might work for others + */ +-(void) registerUser:(NSString*) user withPassword:(NSString*) newPass captcha:(NSString* _Nullable) captcha andHiddenFields:(NSDictionary* _Nullable) hiddenFields +{ + //if no reg form was provided both of these are nil --> don't try to send a reg form in our response + if(captcha != nil && hiddenFields != nil) + { + NSMutableDictionary* fields = [NSMutableDictionary dictionaryWithDictionary:@{ + @"username": user, + @"password": newPass, + }]; + if(captcha) + fields[@"ocr"] = captcha; + [fields addEntriesFromDictionary:hiddenFields]; + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:kRegisterNameSpace withAttributes:@{} andChildren:@[ + [[XMPPDataForm alloc] initWithType:@"submit" formType:kRegisterNameSpace andDictionary:fields] + ] andData:nil]]; + } + else + { + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:kRegisterNameSpace withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"username" andData:user], + [[MLXMLNode alloc] initWithElement:@"password" andData:newPass], + ] andData:nil]]; + } +} + +-(void) changePasswordForUser:(NSString*) user newPassword:(NSString*) newPass +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:kRegisterNameSpace withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"username" andData:user], + [[MLXMLNode alloc] initWithElement:@"password" andData:newPass], + ] andData:nil]]; +} + +-(void) setMucAdminQueryWithAffiliation:(NSString*) affiliation forJid:(NSString*) jid +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"http://jabber.org/protocol/muc#admin" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{ + @"affiliation": affiliation, + @"jid": jid, + } andChildren:@[] andData:nil], + ] andData:nil]]; +} + +#ifdef IS_QUICKSY +-(void) setQuicksyPhoneBook:(NSArray*) numbers +{ + MLXMLNode* envelope = [[MLXMLNode alloc] initWithElement:@"phone-book" andNamespace:@"im.quicksy.synchronization:0"]; + for(NSString* number in numbers) + { + [envelope addChildNode:[[MLXMLNode alloc] initWithElement:@"entry" withAttributes:@{ + @"number": number, + } andChildren:@[] andData:nil]]; + } + [self addChildNode:envelope]; +} +#endif + +@end diff --git a/Monal/Classes/XMPPMessage.h b/Monal/Classes/XMPPMessage.h new file mode 100644 index 0000000..dc9b80a --- /dev/null +++ b/Monal/Classes/XMPPMessage.h @@ -0,0 +1,52 @@ +// +// XMPPMessage.h +// Monal +// +// Created by Anurodh Pokharel on 7/13/13. +// +// + + +#import "XMPPStanza.h" + +FOUNDATION_EXPORT NSString* const kMessageChatType; +FOUNDATION_EXPORT NSString* const kMessageGroupChatType; +FOUNDATION_EXPORT NSString* const kMessageErrorType; +FOUNDATION_EXPORT NSString* const kMessageNormalType; +FOUNDATION_EXPORT NSString* const kMessageHeadlineType; + +@interface XMPPMessage : XMPPStanza + +-(XMPPMessage*) init; +-(XMPPMessage*) initWithType:(NSString*) type to:(NSString*) to; +-(XMPPMessage*) initToContact:(MLContact*) toContact; +-(XMPPMessage*) initWithType:(NSString*) type; +-(XMPPMessage*) initWithXMPPMessage:(XMPPMessage*) msg; + +/** + Sets the body child element + */ +-(void) setBody:(NSString*) messageBody; + +/** + send image uploads out of band + */ +-(void) setOobUrl:(NSString*) link; + +-(void) setLMCFor:(NSString*) id; + +/** + sets the receipt child element + */ +-(void) setReceipt:(NSString*) messageId; +-(void) setDisplayed:(NSString*) messageId; +-(void) setMDSDisplayed:(NSString*) stanzaId withStanzaIdBy:(NSString*) by; + +/** + Hint saying the message should be stored + @see https://xmpp.org/extensions/xep-0334.html + */ +-(void) setStoreHint; +-(void) setNoStoreHint; + +@end diff --git a/Monal/Classes/XMPPMessage.m b/Monal/Classes/XMPPMessage.m new file mode 100644 index 0000000..c5ba952 --- /dev/null +++ b/Monal/Classes/XMPPMessage.m @@ -0,0 +1,139 @@ +// +// XMPPMessage.m +// Monal +// +// Created by Anurodh Pokharel on 7/13/13. +// +// + +#import "XMPPMessage.h" +#import "MLContact.h" + +@class MLContact; + +@interface MLXMLNode() +@property (atomic, strong, readwrite) NSString* element; +@end + +@implementation XMPPMessage + +NSString* const kMessageChatType = @"chat"; +NSString* const kMessageGroupChatType = @"groupchat"; +NSString* const kMessageErrorType = @"error"; +NSString* const kMessageNormalType = @"normal"; +NSString* const kMessageHeadlineType = @"headline"; + +-(XMPPMessage*) init +{ + self = [super init]; + self.element = @"message"; + [self setXMLNS:@"jabber:client"]; + self.attributes[@"type"] = kMessageChatType; //default value, can be overwritten later on + self.id = [[NSUUID UUID] UUIDString]; //default value, can be overwritten later on + return self; +} + +-(XMPPMessage*) initWithType:(NSString*) type to:(NSString*) to +{ + self = [self initWithType:type]; + self.attributes[@"to"] = to; + return self; +} + +-(XMPPMessage*) initToContact:(MLContact*) toContact +{ + self = [self initWithType:(toContact.isMuc ? kMessageGroupChatType : kMessageChatType) to:toContact.contactJid]; + return self; +} + +-(XMPPMessage*) initWithType:(NSString*) type +{ + self = [self init]; + self.attributes[@"type"] = type; + return self; +} + +-(XMPPMessage*) initWithXMPPMessage:(XMPPMessage*) msg +{ + self = [self initWithElement:msg.element withAttributes:msg.attributes andChildren:msg.children andData:msg.data]; + return self; +} + +//this oerwrites the setter of XMPPStanza +-(void) setId:(NSString*) idval +{ + [super setId:idval]; + //add origin id to indicate we are using uuids for our stanza ids + //(modify origin id, if already present) + if([self check:@"{urn:xmpp:sid:0}origin-id"]) + ((MLXMLNode*)[self findFirst:@"{urn:xmpp:sid:0}origin-id"]).attributes[@"id"] = idval; + else + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"origin-id" andNamespace:@"urn:xmpp:sid:0" withAttributes:@{@"id":idval} andChildren:@[] andData:nil]]; +} + +-(void) setBody:(NSString*) messageBody +{ + MLXMLNode* body = [self findFirst:@"body"]; + if(body) + body.data = messageBody; + else + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"body" withAttributes:@{} andChildren:@[] andData:messageBody]]; +} + +-(void) setOobUrl:(NSString*) link +{ + MLXMLNode* oobElement = [self findFirst:@"{jabber:x:oob}x"]; + MLXMLNode* oobElementUrl = [self findFirst:@"{jabber:x:oob}x/url"]; + if(oobElement && oobElementUrl == nil) + [oobElement addChildNode:[[MLXMLNode alloc] initWithElement:@"url" withAttributes:@{} andChildren:@[] andData:link]]; + else if(oobElement && oobElementUrl) + oobElementUrl.data = link; + else + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"x" andNamespace:@"jabber:x:oob" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"url" withAttributes:@{} andChildren:@[] andData:link] + ] andData:nil]]; + [self setBody:link]; //http filetransfers must have a message body equal to the oob link to be recognized as filetransfer +} + +-(void) setLMCFor:(NSString*) id +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"replace" andNamespace:@"urn:xmpp:message-correct:0" withAttributes:@{@"id": id} andChildren:@[] andData:nil]]; +} + +/** + @see https://xmpp.org/extensions/xep-0184.html + */ +-(void) setReceipt:(NSString*) messageId +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"received" andNamespace:@"urn:xmpp:receipts" withAttributes:@{@"id":messageId} andChildren:@[] andData:nil]]; +} + +-(void) setDisplayed:(NSString*) messageId +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"displayed" andNamespace:@"urn:xmpp:chat-markers:0" withAttributes:@{@"id":messageId} andChildren:@[] andData:nil]]; +} + +-(void) setMDSDisplayed:(NSString*) stanzaId withStanzaIdBy:(NSString*) by +{ + [self addChildNode: + [[MLXMLNode alloc] initWithElement:@"displayed" andNamespace:@"urn:xmpp:mds:displayed:0" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"stanza-id" andNamespace:@"urn:xmpp:sid:0" withAttributes:@{ + @"by": by, + @"id": stanzaId, + } andChildren:@[] andData:nil] + ] andData:nil] + ]; +} + +-(void) setStoreHint +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"store" andNamespace:@"urn:xmpp:hints"]]; +} + +-(void) setNoStoreHint +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"no-store" andNamespace:@"urn:xmpp:hints"]]; + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"no-storage" andNamespace:@"urn:xmpp:hints"]]; +} + +@end diff --git a/Monal/Classes/XMPPPresence.h b/Monal/Classes/XMPPPresence.h new file mode 100644 index 0000000..6124e83 --- /dev/null +++ b/Monal/Classes/XMPPPresence.h @@ -0,0 +1,98 @@ +// +// XMPPPresence.h +// Monal +// +// Created by Anurodh Pokharel on 7/5/13. +// +// + +#import "XMPPStanza.h" +#import "MLContact.h" + +NS_ASSUME_NONNULL_BEGIN + +/* + pmuc-v1 = private muc + voice-v1: indicates the user is capable of sending and receiving voice media. + video-v1: indicates the user is capable of receiving video media. + camera-v1: indicates the user is capable of sending video media. + */ + +#define kextpmuc @"pmuc-v1" +#define kextvoice @"voice-v1" +#define kextvideo @"video-v1" +#define kextcamera @"camera-v1" + +@interface XMPPPresence : XMPPStanza +{ + +} + +-(void) setLastInteraction:(NSDate*) date; + +/** + initialte with a version hash string + */ +-(id) initWithHash:(NSString*) version; + +/** + sets a show child with away + */ +-(void) setAway; + +/** + brings a user back from being away + */ +-(void) setAvailable; + +/** + creates and sets the show child + */ +-(void) setShow:(NSString*) showVal; + + +/** + creates and sets the status child + */ +-(void) setStatus:(NSString*) status; + +#pragma mark subscription + +/** + unsubscribes from presence notfiction + */ +-(void) unsubscribeContact:(MLContact*) contact; + +/** + subscribes from presence notfiction + */ +-(void) subscribeContact:(MLContact*) contact; +-(void) subscribeContact:(MLContact*) contact withPreauthToken:(NSString* _Nullable) token; + +/** +allow subscription. Called in response to a remote request. + */ +-(void) subscribedContact:(MLContact*) contact; + +/** + do not allow subscription.Called in response to a remote request. + */ +-(void) unsubscribedContact:(MLContact*) contact; + +#pragma mark MUC + +-(void) createRoom:(NSString*) room withNick:(NSString*) nick; + +/** + join specified room on server + */ +-(void) joinRoom:(NSString*) room withNick:(NSString*) nick; + +/** + leave specified room + */ +-(void) leaveRoom:(NSString*) room withNick:(NSString*) nick; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/XMPPPresence.m b/Monal/Classes/XMPPPresence.m new file mode 100644 index 0000000..7ad0c9b --- /dev/null +++ b/Monal/Classes/XMPPPresence.m @@ -0,0 +1,128 @@ +// +// XMPPPresence.m +// Monal +// +// Created by Anurodh Pokharel on 7/5/13. +// +// + +#import "XMPPPresence.h" +#import "HelperTools.h" +#import "MLContact.h" + +@class MLContact; + +@interface MLXMLNode() +@property (atomic, strong, readwrite) NSString* element; +@end + +@implementation XMPPPresence + +-(id) init +{ + self = [super init]; + self.element = @"presence"; + [self setXMLNS:@"jabber:client"]; + self.attributes[@"id"] = [[NSUUID UUID] UUIDString]; + return self; +} + +-(id) initWithHash:(NSString*) version +{ + self = [self init]; + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"c" andNamespace:@"http://jabber.org/protocol/caps" withAttributes:@{ + @"node": @"https://monal-im.org/", + @"hash": @"sha-1", + @"ver": version + } andChildren:@[] andData:nil]]; + return self; +} + +#pragma mark own state +-(void) setShow:(NSString*) showVal +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"show" withAttributes:@{} andChildren:@[] andData:showVal]]; +} + +-(void) setAway +{ + [self setShow:@"away"]; +} + +-(void) setAvailable +{ + [self setShow:@"chat"]; +} + +-(void) setStatus:(NSString*) status +{ + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"status" withAttributes:@{} andChildren:@[] andData:status]]; +} + +-(void) setLastInteraction:(NSDate*) date +{ + MLXMLNode* idle = [[MLXMLNode alloc] initWithElement:@"idle" andNamespace:@"urn:xmpp:idle:1"]; + [idle.attributes setValue:[HelperTools generateDateTimeString:date] forKey:@"since"]; + [self addChildNode:idle]; +} + +#pragma mark MUC + +-(void) createRoom:(NSString*) room withNick:(NSString*) nick +{ + self.to = [NSString stringWithFormat:@"%@/%@", room, nick]; + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"x" andNamespace:@"http://jabber.org/protocol/muc" withAttributes:@{} andChildren:@[] andData:nil]]; +} + +-(void) joinRoom:(NSString*) room withNick:(NSString*) nick +{ + [self.attributes setObject:[NSString stringWithFormat:@"%@/%@", room, nick] forKey:@"to"]; + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"x" andNamespace:@"http://jabber.org/protocol/muc" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"history" withAttributes:@{@"maxstanzas": @"0"} andChildren:@[] andData:nil] + ] andData:nil]]; +} + + +-(void) leaveRoom:(NSString*) room withNick:(NSString*) nick +{ + self.attributes[@"to"] = [NSString stringWithFormat:@"%@/%@", room, nick]; + self.attributes[@"type"] = @"unavailable"; +} + +#pragma mark subscription + +-(void) unsubscribeContact:(MLContact*) contact +{ + [self.attributes setObject:contact.contactJid forKey:@"to"]; + [self.attributes setObject:@"unsubscribe" forKey:@"type"]; +} + +-(void) subscribeContact:(MLContact*) contact +{ + [self subscribeContact:contact withPreauthToken:nil]; +} + +-(void) subscribedContact:(MLContact*) contact +{ + [self.attributes setObject:contact.contactJid forKey:@"to"]; + [self.attributes setObject:@"subscribed" forKey:@"type"]; +} + +-(void) unsubscribedContact:(MLContact*) contact +{ + [self.attributes setObject:contact.contactJid forKey:@"to"]; + [self.attributes setObject:@"unsubscribed" forKey:@"type"]; +} + +-(void) subscribeContact:(MLContact*) contact withPreauthToken:(NSString* _Nullable) token +{ + [self.attributes setObject:contact.contactJid forKey:@"to"]; + [self.attributes setObject:@"subscribe" forKey:@"type"]; + if(token != nil) + [self addChildNode:[[MLXMLNode alloc] initWithElement:@"preauth" andNamespace:@"urn:xmpp:pars:0" withAttributes:@{ + @"token": token + } andChildren:@[] andData:nil]]; + +} + +@end diff --git a/Monal/Classes/XMPPStanza.h b/Monal/Classes/XMPPStanza.h new file mode 100644 index 0000000..a733f06 --- /dev/null +++ b/Monal/Classes/XMPPStanza.h @@ -0,0 +1,34 @@ +// +// XMPPStanza.h +// monalxmpp +// +// Created by Thilo Molitor on 24.09.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLXMLNode.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface XMPPStanza : MLXMLNode + +-(void) addDelayTagFrom:(NSString*) from; + +@property (atomic, strong) NSString* _Nullable id; + +@property (atomic, strong) NSString* _Nullable from; +@property (atomic, strong) NSString* _Nullable fromUser; +@property (atomic, strong) NSString* _Nullable fromNode; +@property (atomic, strong) NSString* _Nullable fromHost; +@property (atomic, strong) NSString* _Nullable fromResource; + +@property (atomic, strong) NSString* _Nullable to; +@property (atomic, strong) NSString* _Nullable toUser; +@property (atomic, strong) NSString* _Nullable toNode; +@property (atomic, strong) NSString* _Nullable toHost; +@property (atomic, strong) NSString* _Nullable toResource; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/XMPPStanza.m b/Monal/Classes/XMPPStanza.m new file mode 100644 index 0000000..fc673d3 --- /dev/null +++ b/Monal/Classes/XMPPStanza.m @@ -0,0 +1,308 @@ +// +// XMPPStanza.m +// monalxmpp +// +// Created by Thilo Molitor on 24.09.20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "MLConstants.h" +#import "XMPPStanza.h" +#import "HelperTools.h" + +@implementation XMPPStanza + +-(void) addDelayTagFrom:(NSString*) from +{ + MLXMLNode* delay = [[MLXMLNode alloc] initWithElement:@"delay" andNamespace:@"urn:xmpp:delay"]; + delay.attributes[@"from"] = from; + delay.attributes[@"stamp"] = [HelperTools generateDateTimeString:[NSDate date]]; + [self addChildNode:delay]; +} + +-(NSString*) id +{ + @synchronized(self.attributes) { + return self.attributes[@"id"]; + } +} + +-(void) setId:(NSString* _Nullable) id +{ + @synchronized(self.attributes) { + if(!id) + [self.attributes removeObjectForKey:@"id"]; + else + self.attributes[@"id"] = id; + } +} + +-(void) setFrom:(NSString* _Nullable) from +{ + if(from == nil) + { + [self.attributes removeObjectForKey:@"from"]; + return; + } + NSDictionary* jid = [HelperTools splitJid:from]; + @synchronized(self.attributes) { + self.attributes[@"from"] = [NSString stringWithFormat:@"%@%@", jid[@"user"], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + } +} +-(NSString*) from +{ + NSDictionary* jid; + @synchronized(self.attributes) { + if(!self.attributes[@"from"]) + return nil; + jid = [HelperTools splitJid:self.attributes[@"from"]]; + } + return [NSString stringWithFormat:@"%@%@", jid[@"user"], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; +} + +-(void) setFromUser:(NSString* _Nullable) user +{ + @synchronized(self.attributes) { + if(user == nil) + [self.attributes removeObjectForKey:@"from"]; + else + { + if(self.attributes[@"from"] == nil) + self.attributes[@"from"] = [user lowercaseString]; + else + { + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"from"]]; + self.attributes[@"from"] = [NSString stringWithFormat:@"%@%@", [user lowercaseString], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + } + } + } +} +-(NSString*) fromUser +{ + @synchronized(self.attributes) { + if(!self.attributes[@"from"]) + return nil; + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"from"]]; + return jid[@"user"]; + } +} + +-(void) setFromNode:(NSString* _Nullable) node +{ + @synchronized(self.attributes) { + if(self.attributes[@"from"] == nil) + MLAssert(node == nil, @"You can't set a node value if there's no host!"); + else + { + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"from"]]; + MLAssert(jid[@"host"] != nil, @"You can't set a node value if there's no host!"); + if(node == nil) + self.attributes[@"from"] = [NSString stringWithFormat:@"%@%@", jid[@"host"], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + else + self.attributes[@"from"] = [NSString stringWithFormat:@"%@@%@%@", [node lowercaseString], jid[@"host"], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + } + } +} +-(NSString*) fromNode +{ + @synchronized(self.attributes) { + if(!self.attributes[@"from"]) + return nil; + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"from"]]; + return jid[@"node"]; + } +} + +-(void) setFromHost:(NSString* _Nullable) host +{ + @synchronized(self.attributes) { + if(self.attributes[@"from"] == nil) + { + if(host == nil) + ; // do nothing, everything's already nil + else + self.attributes[@"from"] = [host lowercaseString]; + } + else + { + if(host == nil) + [self.attributes removeObjectForKey:@"from"]; + else + { + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"from"]]; + if(jid[@"node"]) + self.attributes[@"from"] = [NSString stringWithFormat:@"%@@%@%@", jid[@"node"], [host lowercaseString], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + else + self.attributes[@"from"] = [NSString stringWithFormat:@"%@%@", [host lowercaseString], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + } + } + } +} +-(NSString*) fromHost +{ + @synchronized(self.attributes) { + if(!self.attributes[@"from"]) + return nil; + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"from"]]; + return jid[@"host"]; + } +} + +-(void) setFromResource:(NSString*) resource +{ + @synchronized(self.attributes) { + if(self.attributes[@"from"] == nil) + return; // do nothing: we can't set a resource if we don't have a host + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"from"]]; + if(jid[@"user"] == nil) + return; // do nothing: we can't set a resource if we don't have a host + else + self.attributes[@"from"] = [NSString stringWithFormat:@"%@%@", jid[@"user"], resource && ![resource isEqualToString:@""] ? [NSString stringWithFormat:@"/%@", resource] : @""]; + } +} +-(NSString*) fromResource +{ + @synchronized(self.attributes) { + if(!self.attributes[@"from"]) + return nil; + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"from"]]; + return jid[@"resource"]; + } +} + + +-(void) setTo:(NSString* _Nullable) to +{ + if(to == nil) + { + [self.attributes removeObjectForKey:@"to"]; + return; + } + NSDictionary* jid = [HelperTools splitJid:to]; + @synchronized(self.attributes) { + self.attributes[@"to"] = [NSString stringWithFormat:@"%@%@", jid[@"user"], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + } +} +-(NSString*) to +{ + NSDictionary* jid; + @synchronized(self.attributes) { + if(!self.attributes[@"to"]) + return nil; + jid = [HelperTools splitJid:self.attributes[@"to"]]; + } + return [NSString stringWithFormat:@"%@%@", jid[@"user"], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; +} + +-(void) setToUser:(NSString* _Nullable) user +{ + @synchronized(self.attributes) { + if(user == nil) + [self.attributes removeObjectForKey:@"to"]; + else + { + if(self.attributes[@"to"] == nil) + self.attributes[@"to"] = [user lowercaseString]; + else + { + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"to"]]; + self.attributes[@"to"] = [NSString stringWithFormat:@"%@%@", [user lowercaseString], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + } + } + } +} +-(NSString*) toUser +{ + @synchronized(self.attributes) { + if(!self.attributes[@"to"]) + return nil; + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"to"]]; + return jid[@"user"]; + } +} + +-(void) setToNode:(NSString* _Nullable) node +{ + @synchronized(self.attributes) { + if(self.attributes[@"to"] == nil) + MLAssert(node == nil, @"You can't set a node value if there's no host!"); + else + { + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"to"]]; + MLAssert(jid[@"host"] != nil, @"You can't set a node value if there's no host!"); + if(node == nil) + self.attributes[@"to"] = [NSString stringWithFormat:@"%@%@", jid[@"host"], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + else + self.attributes[@"to"] = [NSString stringWithFormat:@"%@@%@%@", [node lowercaseString], jid[@"host"], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + } + } +} +-(NSString*) toNode +{ + @synchronized(self.attributes) { + if(!self.attributes[@"to"]) + return nil; + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"to"]]; + return jid[@"node"]; + } +} + +-(void) setToHost:(NSString* _Nullable) host +{ + @synchronized(self.attributes) { + if(self.attributes[@"to"] == nil) + { + if(host == nil) + ; // do nothing, everything's already nil + else + self.attributes[@"to"] = [host lowercaseString]; + } + else + { + if(host == nil) + [self.attributes removeObjectForKey:@"to"]; + else + { + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"to"]]; + if(jid[@"node"]) + self.attributes[@"to"] = [NSString stringWithFormat:@"%@@%@%@", jid[@"node"], [host lowercaseString], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + else + self.attributes[@"to"] = [NSString stringWithFormat:@"%@%@", [host lowercaseString], jid[@"resource"] ? [NSString stringWithFormat:@"/%@", jid[@"resource"]] : @""]; + } + } + } +} +-(NSString*) toHost +{ + @synchronized(self.attributes) { + if(!self.attributes[@"to"]) + return nil; + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"to"]]; + return jid[@"host"]; + } +} + +-(void) setToResource:(NSString*) resource +{ + @synchronized(self.attributes) { + if(self.attributes[@"to"] == nil) + return; // do nothing: we can't set a resource if we don't have a host + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"to"]]; + if(jid[@"user"] == nil) + return; // do nothing: we can't set a resource if we don't have a host + else + self.attributes[@"to"] = [NSString stringWithFormat:@"%@%@", jid[@"user"], resource && ![resource isEqualToString:@""] ? [NSString stringWithFormat:@"/%@", resource] : @""]; + } +} +-(NSString*) toResource +{ + @synchronized(self.attributes) { + if(!self.attributes[@"to"]) + return nil; + NSDictionary* jid = [HelperTools splitJid:self.attributes[@"to"]]; + return jid[@"resource"]; + } +} + +@end diff --git a/Monal/Classes/ZoomableContainer.swift b/Monal/Classes/ZoomableContainer.swift new file mode 100644 index 0000000..4559414 --- /dev/null +++ b/Monal/Classes/ZoomableContainer.swift @@ -0,0 +1,132 @@ +// +// ImageViewer.swift +// Monal +// +// Created by Thilo Molitor on 10.10.23. +// Copyright © 2023 monal-im.org. All rights reserved. +// + +//based upon: https://stackoverflow.com/a/76649224/3528174 +struct ZoomableContainer: View { + let content: Content + let maxScale: CGFloat + let doubleTapScale: CGFloat + @State private var currentScale: CGFloat = 1.0 + @State private var tapLocation: CGPoint = .zero + + init(maxScale:CGFloat = 4.0, doubleTapScale:CGFloat = 4.0, @ViewBuilder content: () -> Content) { + self.content = content() + self.maxScale = maxScale + self.doubleTapScale = doubleTapScale + } + + var body: some View { + //ios 17+ will zoom to the point the double tap was done, older ios versions will zoom to the center of the image instead + if #available(iOS 17.0, macCatalyst 17.0, *) { + ZoomableScrollView(maxScale: maxScale, scale: $currentScale, tapLocation: $tapLocation) { + content + }.onTapGesture(count: 2, perform: {location in + tapLocation = location + currentScale = currentScale == 1.0 ? doubleTapScale : 1.0 + }) + } else { + GeometryReader { proxy in + ZoomableScrollView(maxScale: maxScale, scale: $currentScale, tapLocation: $tapLocation) { + content + }.onTapGesture(count: 2) { + tapLocation = CGPoint(x:proxy.size.width/2, y:proxy.size.height/2) + currentScale = currentScale == 1.0 ? doubleTapScale : 1.0 + } + } + } + } + + fileprivate struct ZoomableScrollView: UIViewRepresentable { + private var content: InnerContent + let maxScale: CGFloat + @Binding private var currentScale: CGFloat + @Binding private var tapLocation: CGPoint + + init(maxScale: CGFloat, scale: Binding, tapLocation: Binding, @ViewBuilder content: () -> InnerContent) { + self.maxScale = maxScale + _currentScale = scale + _tapLocation = tapLocation + self.content = content() + } + + func makeUIView(context: Context) -> UIScrollView { + // Setup the UIScrollView + let scrollView = UIScrollView() + scrollView.delegate = context.coordinator // for viewForZooming(in:) + scrollView.maximumZoomScale = maxScale + scrollView.minimumZoomScale = 1 + scrollView.bouncesZoom = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.clipsToBounds = false + + // Create a UIHostingController to hold our SwiftUI content + let hostedView = context.coordinator.hostingController.view! + hostedView.translatesAutoresizingMaskIntoConstraints = true + hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + hostedView.frame = scrollView.bounds + scrollView.addSubview(hostedView) + + return scrollView + } + + func makeCoordinator() -> Coordinator { + return Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale) + } + + func updateUIView(_ uiView: UIScrollView, context: Context) { + // Update the hosting controller's SwiftUI content + context.coordinator.hostingController.rootView = content + + if uiView.zoomScale > uiView.minimumZoomScale { // Scale out + uiView.setZoomScale(currentScale, animated: true) + } else if tapLocation != .zero { // Scale in to a specific point + uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true) + } + + // Reset the location to prevent scaling to it in case of a negative scale (manual pinch) + // Use the main thread to prevent unexpected behavior + DispatchQueue.main.async { tapLocation = .zero } + + assert(context.coordinator.hostingController.view.superview == uiView) + } + + // MARK: - Utils + + func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect { + let scrollViewSize = scrollView.bounds.size + + let width = scrollViewSize.width / scale + let height = scrollViewSize.height / scale + let x = center.x - (width / 2.0) + let y = center.y - (height / 2.0) + + return CGRect(x: x, y: y, width: width, height: height) + } + + // MARK: - Coordinator + + class Coordinator: NSObject, UIScrollViewDelegate { + var hostingController: UIHostingController + @Binding var currentScale: CGFloat + + init(hostingController: UIHostingController, scale: Binding) { + self.hostingController = hostingController + _currentScale = scale + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return hostingController.view + } + + func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + currentScale = scale + } + } + } +} diff --git a/Monal/Classes/chatViewController.h b/Monal/Classes/chatViewController.h new file mode 100644 index 0000000..f38ec1b --- /dev/null +++ b/Monal/Classes/chatViewController.h @@ -0,0 +1,89 @@ +// +// chatViewController.h +// SworIM +// +// Created by Anurodh Pokharel on 1/25/09. +// Copyright 2009 __MyCompanyName__. All rights reserved. +// + +#import +#import +#import +#import + +#import "DataLayer.h" +#import "MLConstants.h" +#import "MLXMPPManager.h" +#import "MLNotificationManager.h" +#import "MLResizingTextView.h" +#import "MLSearchViewController.h" +#import "MLFileTransferDataCell.h" +#import "MLFileTransferVideoCell.h" +#import "MLFileTransferTextCell.h" +#import "MLFileTransferFileViewController.h" +#import "MLAudioRecoderManager.h" +#import "MLUploadQueueCell.h" + +@interface chatViewController : UIViewController +{ + UIView* containerView; + BOOL _firstmsg; +} + +@property (nonatomic, retain) CLLocationManager* locationManager; + +@property (nonatomic, weak) IBOutlet UITableView* messageTable; +@property (nonatomic, weak) IBOutlet MLResizingTextView* chatInput; +@property (nonatomic, weak) IBOutlet UILabel* placeHolderText; +@property (nonatomic, weak) IBOutlet UIButton* sendButton; +@property (nonatomic, weak) IBOutlet UIButton* plusButton; +@property (nonatomic, weak) IBOutlet UIButton* audioButton; +@property (weak, nonatomic) IBOutlet UICollectionView* uploadMenuView; + +@property (nonatomic, weak) IBOutlet UIView* inputContainerView; +@property (nonatomic, weak) IBOutlet NSLayoutConstraint* tableviewBottom; +@property (nonatomic, strong) UILabel* navBarContactJid; +@property (nonatomic, strong) UILabel* navBarLastInteraction; +@property (nonatomic, strong) IBOutlet UIImageView* navBarIcon; +@property (nonatomic, strong) UIBarButtonItem* customHeader; + +@property (weak, nonatomic) IBOutlet UIBarButtonItem* navBarEncryptToggleButton; + +@property (nonatomic, weak) IBOutlet UIImageView* backgroundImage; +@property (nonatomic, weak) IBOutlet UIView* transparentLayer; +@property (weak, nonatomic) IBOutlet UIButton* audioRecordButton; + +@property (nonatomic, strong) MLContact* contact; + +/** + full own username with domain e.g. user@example.org + */ +@property (nonatomic, strong) NSString* jid; + +-(IBAction)sendMessageText:(id)sender; +// attach image +-(IBAction)attach:(id)sender; +// attach file +-(IBAction)attachfile:(id)sender; + +-(IBAction)dismissKeyboard:(id)sender; + +-(void) setupWithContact:(MLContact *) contact; + +-(void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations; +-(void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error; + +/** + Receives the new message notice and will update if it is this user. + */ +-(void) handleNewMessage:(NSNotification *)notification; + +-(void) retry:(id) sender; + +-(void) reloadTable; + +-(void) showUploadHUD; +-(void) hideUploadHUD; +-(void) scrollToBottomAnimated:(BOOL) animated; + +@end diff --git a/Monal/Classes/chatViewController.m b/Monal/Classes/chatViewController.m new file mode 100644 index 0000000..a15318d --- /dev/null +++ b/Monal/Classes/chatViewController.m @@ -0,0 +1,3627 @@ +// +// chat.m +// SworIM +// +// Created by Anurodh Pokharel on 1/25/09. +// Copyright 2009 __MyCompanyName__. All rights reserved. +// + +#import "chatViewController.h" +#import "MLChatCell.h" +#import "MLChatImageCell.h" +#import "MLChatMapsCell.h" +#import "MLLinkCell.h" +#import "MLReloadCell.h" +#import "MLUploadQueueCell.h" + +#import "ActiveChatsViewController.h" +#import "AESGcm.h" +#import "DataLayer.h" +#import "HelperTools.h" +#import "MBProgressHUD.h" +#import "MLChatInputContainer.h" +#import "MLChatViewHelper.h" +#import "MLConstants.h" +#import "MLFiletransfer.h" +#import "MLImageManager.h" +#import "MLMucProcessor.h" +#import "MLVoIPProcessor.h" +#import "MLNotificationQueue.h" +#import "MLOMEMO.h" +#import "MLSearchViewController.h" +#import "MLXEPSlashMeHandler.h" +#import "MonalAppDelegate.h" +#import "xmpp.h" +#import "XMPPMessage.h" + +#import +#import + +#define UPLOAD_TYPE_IMAGE @"UploadTypeImage"; +#define UPLOAD_TYPE_URL @"UploadTypeURL"; + +@import AVFoundation; +@import MobileCoreServices; +@import QuartzCore.CATransaction; +@import QuartzCore; +@import UniformTypeIdentifiers.UTCoreTypes; + +@class MLEmoji; + +@interface chatViewController() +{ + BOOL _isTyping; + monal_void_block_t _cancelTypingNotification; + monal_void_block_t _cancelLastInteractionTimer; + NSMutableDictionary* _localMLContactCache; + BOOL _isRecording; + BOOL _isAtBottom; + monal_void_block_t _scrollToBottomTimer; +} + +@property (nonatomic, strong) NSDateFormatter* destinationDateFormat; +@property (nonatomic, strong) NSCalendar* gregorian; +@property (nonatomic, assign) NSInteger thisyear; +@property (nonatomic, assign) NSInteger thismonth; +@property (nonatomic, assign) NSInteger thisday; +@property (nonatomic, strong) MBProgressHUD* uploadHUD; +@property (nonatomic, strong) MBProgressHUD* gpsHUD; +@property (nonatomic, strong) MBProgressHUD* omemoHUD; +@property (nonatomic, strong) UIBarButtonItem* callButton; + +@property (nonatomic, strong) NSMutableArray* messageList; +@property (nonatomic, strong) UIDocumentPickerViewController* filePicker; + +@property (nonatomic, assign) BOOL sendLocation; // used for first request + +@property (nonatomic, strong) NSDate* lastMamDate; +@property (nonatomic, assign) BOOL hardwareKeyboardPresent; +@property (nonatomic, strong) xmpp* xmppAccount; + +@property (nonatomic, strong) NSLayoutConstraint* chatInputConstraintHWKeyboard; +@property (nonatomic, strong) NSLayoutConstraint* chatInputConstraintSWKeyboard; + +//infinite scrolling +@property (atomic) BOOL viewDidAppear; +@property (atomic) BOOL viewIsScrolling; +@property (atomic) BOOL isLoadingMam; +@property (atomic) BOOL moreMessagesAvailable; + +@property (nonatomic, strong) UIButton* lastMsgButton; +//SearchViewController, SearchResultViewController +@property (nonatomic, strong) MLSearchViewController* searchController; +@property (nonatomic, strong) NSMutableArray* searchResultMessageList; + +// Upload Queue +@property (nonatomic, strong) NSMutableOrderedSet* uploadQueue; +@property (nonatomic, strong) NSLayoutConstraint* uploadMenuConstraint; + +@property (nonatomic, strong) void (^editingCallback)(NSString* newBody); +@property (nonatomic, strong) NSMutableSet* previewedIds; + +@property (atomic) BOOL isAudioMessage; +@property (nonatomic) UILongPressGestureRecognizer* longGestureRecognizer; + +@property (nonatomic) UIView* audioRecoderInfoView; + +#define LAST_MSG_BUTTON_OFFSET 5 +#define LAST_MSG_BUTTON_SIZE 40.0 + +@end + +@class HelperTools; + +@implementation chatViewController + +enum chatViewControllerSections { + reloadBoxSection, + messagesSection, + chatViewControllerSectionCnt +}; + +enum msgSentState { + msgSent, + msgErrorAfterSent, + msgRecevied, + msgDisplayed +}; + +-(void) setupWithContact:(MLContact*) contact +{ + self.contact = contact; + [self setup]; +} + +-(void) setup +{ + self.hidesBottomBarWhenPushed = YES; + + NSDictionary* accountDict = [[DataLayer sharedInstance] detailsForAccount:self.contact.accountID]; + if(accountDict) + self.jid = [NSString stringWithFormat:@"%@@%@",[accountDict objectForKey:@"username"], [accountDict objectForKey:@"domain"]]; + + self.previewedIds = [NSMutableSet new]; + + _localMLContactCache = [[NSMutableDictionary alloc] init]; +} + +#pragma mark - view lifecycle + +-(void) viewDidLoad +{ + [super viewDidLoad]; + + if([[DataLayer sharedInstance] isContactInList:self.contact.contactJid forAccount:self.contact.accountID] == NO) + { + DDLogWarn(@"ChatView: Contact %@ is unkown", self.contact.contactJid); +#ifdef IS_ALPHA + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Contact is unkown - GUI error" userInfo:nil]; +#endif + } + + [self initNavigationBarItems]; + + [self setupDateObjects]; + containerView = self.view; + self.messageTable.scrollsToTop = YES; + self.chatInput.scrollsToTop = NO; + self.editingCallback = nil; + + self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; + + _isTyping = NO; + self.hidesBottomBarWhenPushed=YES; + + self.chatInput.layer.borderColor = [UIColor lightGrayColor].CGColor; + self.chatInput.layer.cornerRadius = 3.0f; + self.chatInput.layer.borderWidth = 0.5f; + self.chatInput.textContainerInset = UIEdgeInsetsMake(5, 0, 5, 0); + + self.messageTable.rowHeight = UITableViewAutomaticDimension; + self.messageTable.estimatedRowHeight = UITableViewAutomaticDimension; + +#if TARGET_OS_MACCATALYST + //does not become first responder like in iOS + [self.view addSubview:self.inputContainerView]; + + [self.inputContainerView.leadingAnchor constraintEqualToAnchor:self.inputContainerView.superview.leadingAnchor].active = YES; + [self.inputContainerView.bottomAnchor constraintEqualToAnchor:self.inputContainerView.superview.bottomAnchor].active = YES; + [self.inputContainerView.trailingAnchor constraintEqualToAnchor:self.inputContainerView.superview.trailingAnchor].active = YES; + self.tableviewBottom.constant += 20; +#endif + self.filePicker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeItem]]; + self.filePicker.allowsMultipleSelection = YES; + self.filePicker.delegate = self; + + // Set max height of the chatInput (The chat should be still readable while the HW-Keyboard is active + self.chatInputConstraintHWKeyboard = [NSLayoutConstraint constraintWithItem:self.chatInput attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationLessThanOrEqual toItem:nil attribute:NSLayoutAttributeHeight multiplier:1 constant:self.view.frame.size.height * 0.6]; + self.chatInputConstraintSWKeyboard = [NSLayoutConstraint constraintWithItem:self.chatInput attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationLessThanOrEqual toItem:nil attribute:NSLayoutAttributeHeight multiplier:1 constant:self.view.frame.size.height * 0.4]; + self.uploadMenuConstraint = [NSLayoutConstraint + constraintWithItem:self.uploadMenuView + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:nil + attribute:NSLayoutAttributeNotAnAttribute + multiplier:1.0 + constant:0]; // Constant will be set through showUploadQueue + [self.inputContainerView addConstraint:self.chatInputConstraintHWKeyboard]; + [self.inputContainerView addConstraint:self.chatInputConstraintSWKeyboard]; + [self.uploadMenuView addConstraint:self.uploadMenuConstraint]; + + [self setChatInputHeightConstraints:YES]; + +#if !TARGET_OS_MACCATALYST + [self initAudioRecordButton]; +#endif + + // setup refreshControl for infinite scrolling + UIRefreshControl* refreshControl = [UIRefreshControl new]; + [refreshControl addTarget:self action:@selector(loadOldMsgHistory:) forControlEvents:UIControlEventValueChanged]; + refreshControl.attributedTitle = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"Loading more Messages from Server", @"")]; + [self.messageTable setRefreshControl:refreshControl]; + self.moreMessagesAvailable = YES; + + self.uploadQueue = [[NSMutableOrderedSet alloc] init]; + + [self.messageTable addInteraction:[[UIDropInteraction alloc] initWithDelegate:self]]; + [self.inputContainerView addInteraction:[[UIDropInteraction alloc] initWithDelegate:self]]; + +#ifdef DISABLE_OMEMO + NSMutableArray* rightBarButtons = [NSMutableArray new]; + for(UIBarButtonItem* entry in self.navigationItem.rightBarButtonItems) + if(entry.action != @selector(toggleEncryption:)) + [rightBarButtons addObject:entry]; + self.navigationItem.rightBarButtonItems = rightBarButtons; +#endif + + [self updateCallButtonImage]; +} + +-(void) updateCallButtonImage +{ + if([HelperTools shouldProvideVoip]) + { + //this has to be done in the main thread because it's ui related + //use reentrant dispatch to make sure we update the call button in one shot to not let it flicker + //this does not matter if we aren't already in the main thread, hence the async dispatch + [HelperTools dispatchAsync:YES reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ + //these contact types can not be called + if(self.contact.isMuc || self.contact.isSelfChat) + { + self.callButton = nil; + + //remove call button, if present + NSMutableArray* rightBarButtons = [NSMutableArray new]; + for(UIBarButtonItem* entry in self.navigationItem.rightBarButtonItems) + if(entry.action != @selector(openCallScreen:)) + [rightBarButtons addObject:entry]; + self.navigationItem.rightBarButtonItems = rightBarButtons; + + return; + } + + if(self.callButton == nil) + { + self.callButton = [UIBarButtonItem new]; + [self.callButton setAction:@selector(openCallScreen:)]; + } + + MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate]; + MLCall* activeCall = [appDelegate.voipProcessor getActiveCallWithContact:self.contact]; + if(activeCall != nil) + self.callButton.image = [UIImage systemImageNamed:@"phone.connection.fill"]; + else + self.callButton.image = [UIImage systemImageNamed:@"phone.fill"]; + + //add the button to the bar button items if not already present + BOOL present = NO; + for(UIBarButtonItem* entry in self.navigationItem.rightBarButtonItems) + if(entry.action == @selector(openCallScreen:)) + present = YES; + if(!present) + { + NSMutableArray* rightBarButtons = [self.navigationItem.rightBarButtonItems mutableCopy]; + [rightBarButtons addObject:self.callButton]; + self.navigationItem.rightBarButtonItems = rightBarButtons; + } + }]; + } +} + +-(void) initNavigationBarItems +{ + UIView* cusView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 120, self.navigationController.navigationBar.frame.size.height)]; + + self.navBarIcon = [[UIImageView alloc] initWithFrame:CGRectMake(0, 7, 30, 30)]; + self.navBarContactJid = [[UILabel alloc] initWithFrame:CGRectMake(38, 7, 200, 18)]; + self.navBarLastInteraction = [[UILabel alloc] initWithFrame:CGRectMake(38, 26, 200, 12)]; + + self.navBarContactJid.font = [UIFont systemFontOfSize:15.0]; + self.navBarLastInteraction.font = [UIFont systemFontOfSize:10.0]; + + [cusView addSubview:self.navBarIcon]; + [cusView addSubview:self.navBarContactJid]; + [cusView addSubview:self.navBarLastInteraction]; + + UITapGestureRecognizer* openContactDetailsTapAction = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(commandIPressed:)]; + [cusView addGestureRecognizer:openContactDetailsTapAction]; + + UIBarButtonItem* customViewButtonWithMultipleItems = [[UIBarButtonItem alloc] initWithCustomView:cusView]; + [customViewButtonWithMultipleItems setAction:@selector(commandIPressed:)]; + + // allow opening of contact details via voice over + [customViewButtonWithMultipleItems setIsAccessibilityElement:YES]; + [customViewButtonWithMultipleItems setAccessibilityTraits:UIAccessibilityTraitAllowsDirectInteraction]; + [customViewButtonWithMultipleItems setAccessibilityLabel:self.navBarContactJid.text]; + + self.customHeader = customViewButtonWithMultipleItems; + + self.navigationItem.leftBarButtonItems = @[customViewButtonWithMultipleItems]; + self.navigationItem.leftItemsSupplementBackButton = YES; +} + +-(void) initLastMsgButton +{ + unichar arrowSymbol = 0x2193; + + self.lastMsgButton = [UIButton new]; + self.lastMsgButton.layer.cornerRadius = LAST_MSG_BUTTON_SIZE/2; + self.lastMsgButton.layer.backgroundColor = [UIColor whiteColor].CGColor; + [self.lastMsgButton setTitleColor:[UIColor grayColor] forState:UIControlStateNormal]; + [self.lastMsgButton setTitle:[NSString stringWithCharacters:&arrowSymbol length:1] forState:UIControlStateNormal]; + self.lastMsgButton.titleLabel.font = [UIFont systemFontOfSize:30.0]; + self.lastMsgButton.layer.borderColor = [UIColor grayColor].CGColor; + self.lastMsgButton.userInteractionEnabled = YES; + [self.lastMsgButton setHidden:YES]; + [self.inputContainerView addSubview:self.lastMsgButton]; + [self positionLastMsgButtonAboveInputContainerView]; + MLChatInputContainer* inputView = (MLChatInputContainer*) self.inputContainerView; + inputView.chatInputActionDelegate = self; +} + +-(void) initAudioRecordButton +{ + self.longGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(recordMessageAudio:)]; + self.longGestureRecognizer.minimumPressDuration = 0.8; + [self.audioRecordButton addGestureRecognizer:self.longGestureRecognizer]; + + [self.sendButton setHidden:YES]; + self.isAudioMessage = YES; +} + +-(void) positionLastMsgButtonAboveInputContainerView +{ + self.lastMsgButton.translatesAutoresizingMaskIntoConstraints = NO; + [self.inputContainerView addConstraints:@[ + [NSLayoutConstraint constraintWithItem:self.lastMsgButton + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:self.inputContainerView + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:-LAST_MSG_BUTTON_OFFSET], + [NSLayoutConstraint constraintWithItem:self.lastMsgButton + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.inputContainerView + attribute:NSLayoutAttributeTop + multiplier:1.0 + constant:-LAST_MSG_BUTTON_OFFSET], + ]]; + [self.lastMsgButton.widthAnchor constraintEqualToConstant:LAST_MSG_BUTTON_SIZE].active = YES; + [self.lastMsgButton.heightAnchor constraintEqualToConstant:LAST_MSG_BUTTON_SIZE].active = YES; +} + +#pragma mark - ChatInputActionDelegage +-(void) doScrollDownAction +{ + [self scrollToBottomAnimated:YES]; +} + +#pragma mark - SearchViewController +-(void) initSearchViewControler +{ + self.searchController = [[MLSearchViewController alloc] initWithSearchResultsController:nil]; + [self.searchController setObscuresBackgroundDuringPresentation:NO]; + self.searchController.searchResultDelegate = self; + self.searchController.jid = self.jid; + self.searchResultMessageList = [NSMutableArray new]; +} + +-(void) initSearchButtonItem +{ + UIBarButtonItem* seachButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch + target:self + action:@selector(showSeachButtonAction)]; + + NSMutableArray* rightBarButtons = [self.navigationItem.rightBarButtonItems mutableCopy]; + [rightBarButtons addObject:seachButton]; + self.navigationItem.rightBarButtonItems = rightBarButtons; +} + +-(void) showSeachButtonAction +{ + self.searchController.contact = self.contact; + if(!(self.searchController.isViewLoaded && self.searchController.view.window)) + [self presentViewController:self.searchController animated:NO completion:nil]; +} + +-(void) dismissSearchViewControllerAction +{ + [self.searchController dismissViewControllerAnimated:NO completion:nil]; +} + +#pragma mark - SearchResultVCActionDelegate + +-(void) doGoSearchResultAction:(NSNumber*)nextDBId +{ + NSNumber* messagePathIdx = [self.searchController getMessageIndexPathForDBId:nextDBId]; + if (messagePathIdx != nil) + { + long nextPathIdx = [messagePathIdx longValue]; + NSIndexPath* msgIdxPath = [NSIndexPath indexPathForRow:nextPathIdx inSection:messagesSection]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.messageTable scrollToRowAtIndexPath:msgIdxPath atScrollPosition:UITableViewScrollPositionMiddle animated:NO]; + MLBaseCell* selectedCell = [self.messageTable cellForRowAtIndexPath:msgIdxPath]; + UIColor* originColor = [selectedCell.backgroundColor copy]; + selectedCell.backgroundColor = [UIColor lightGrayColor]; + + [UIView animateWithDuration:0.2 delay:0.2 options:UIViewAnimationOptionCurveLinear animations:^{ + selectedCell.backgroundColor = originColor; + } completion:nil]; + }); + } +} + +-(void) doReloadHistoryForSearch +{ + [self loadOldMsgHistory]; +} + +- (void) doReloadActionForAllTableView +{ + [self.messageTable reloadData]; +} + +- (void) doGetMsgData +{ + for (unsigned int idx = 0; idx < self.messageList.count; idx++) + { + MLMessage* msg = [self.messageList objectAtIndex:idx]; + [self doSetMsgPathIdx:idx withDBId:msg.messageDBId]; + } +} + +-(void) doSetNotLoadingHistory +{ + if (self.searchController.isActive) + { + self.searchController.isLoadingHistory = NO; + [self.searchController setResultToolBar]; + } + [self doGetMsgData]; +} + +-(void)doShowLoadingHistory:(NSString *)title +{ + UIAlertController *loadingWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Hint", @"") + message:title preferredStyle:UIAlertControllerStyleAlert]; + + [self presentViewController:loadingWarning animated:YES completion:^{ + dispatch_queue_t queue = dispatch_get_main_queue(); + dispatch_after(2.0, queue, ^{ + [loadingWarning dismissViewControllerAnimated:YES completion:nil]; + }); + }]; +} + + +-(void) doSetMsgPathIdx:(NSInteger) pathIdx withDBId:(NSNumber *) messageDBId +{ + if(messageDBId != nil) + [self.searchController setMessageIndexPath:[NSNumber numberWithInteger:pathIdx] withDBId:messageDBId]; +} + +-(BOOL) isContainKeyword:(NSNumber *) messageDBId +{ + if([self.searchController getMessageIndexPathForDBId:messageDBId] != nil) + return YES; + return NO; +} + +-(void) resetHistoryAttributeForCell:(MLBaseCell*) cell +{ + if(!cell.messageBody.text) + return; + + NSMutableAttributedString *defaultAttrString = [[NSMutableAttributedString alloc] initWithString:cell.messageBody.text]; + NSInteger textLength = (cell.messageBody.text == nil) ? 0: cell.messageBody.text.length; + NSRange defaultTextRange = NSMakeRange(0, textLength); + [defaultAttrString addAttribute:NSBackgroundColorAttributeName value:[UIColor clearColor] range:defaultTextRange]; + cell.messageBody.attributedText = defaultAttrString; + cell.textLabel.backgroundColor = [UIColor clearColor]; +} + + +-(void) setChatInputHeightConstraints:(BOOL) hwKeyboardPresent +{ + if(!self.chatInputConstraintHWKeyboard || !self.chatInputConstraintSWKeyboard) + return; + + // activate / disable constraints depending on keyboard type + self.chatInputConstraintHWKeyboard.active = hwKeyboardPresent; + self.chatInputConstraintSWKeyboard.active = !hwKeyboardPresent; + + [self.inputContainerView layoutIfNeeded]; +} + +-(void) handleForeGround +{ + [HelperTools dispatchAsync:YES reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ + @synchronized(self->_localMLContactCache) { + [self->_localMLContactCache removeAllObjects]; + } + [self refreshData]; + [self reloadTable]; + }]; +} + +-(void) openCallScreen:(id) sender +{ + MLAssert(sender != nil || self.callButton != nil, @"We need at least one ui source (e.g. button) to base the popover controller upon!"); + if(sender == nil) + sender = self.callButton; + + MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate]; + MLCall* activeCall = [appDelegate.voipProcessor getActiveCallWithContact:self.contact]; + if(activeCall == nil && ![[DataLayer sharedInstance] checkCap:@"urn:xmpp:jingle-message:0" forUser:self.contact.contactJid onAccountID:self.contact.accountID]) + { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Missing Call Support", @"") message:NSLocalizedString(@"Your contact may not support calls. Your call might never reach its destination.", @"") preferredStyle:UIAlertControllerStyleActionSheet]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Try nevertheless", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + [self dismissViewControllerAnimated:YES completion:nil]; + + //now initiate call + MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate]; + [appDelegate.activeChats callContact:self.contact withUIKitSender:sender]; + }]]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { + [self dismissViewControllerAnimated:YES completion:nil]; + }]]; + UIPopoverPresentationController* popPresenter = [alert popoverPresentationController]; + popPresenter.sourceItem = sender; + [self presentViewController:alert animated:YES completion:nil]; + } + else + { + MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate]; + [appDelegate.activeChats callContact:self.contact withUIKitSender:sender]; + } +} + +-(IBAction) toggleEncryption:(id) sender +{ + if([HelperTools isContactBlacklistedForEncryption:self.contact]) + return; +#ifndef DISABLE_OMEMO + if(self.contact.isEncrypted) + { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Disable encryption?", @"") message:NSLocalizedString(@"Do you really want to disable encryption for this contact?", @"") preferredStyle:UIAlertControllerStyleActionSheet]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Yes, deactivate encryption", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + [MLChatViewHelper toggleEncryptionForContact:self.contact withSelf:self afterToggle:^() { + [self displayEncryptionStateInUI]; + }]; + [self dismissViewControllerAnimated:YES completion:nil]; + }]]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"No, keep encryption activated", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { + [self dismissViewControllerAnimated:YES completion:nil]; + }]]; + UIPopoverPresentationController* popPresenter = [alert popoverPresentationController]; + popPresenter.sourceItem = sender; + [self presentViewController:alert animated:YES completion:nil]; + } + else + [MLChatViewHelper toggleEncryptionForContact:self.contact withSelf:self afterToggle:^() { + [self displayEncryptionStateInUI]; + }]; +#endif +} + +-(void) observeValueForKeyPath:(NSString*) keyPath ofObject:(id) object change:(NSDictionary *) change context:(void*) context +{ + if([keyPath isEqualToString:@"isEncrypted"] && object == self.contact) + dispatch_async(dispatch_get_main_queue(), ^{ + [self displayEncryptionStateInUI]; + }); +} + +-(void) displayEncryptionStateInUI +{ + if(self.contact.isEncrypted) + [self.navBarEncryptToggleButton setImage:[UIImage imageNamed:@"744-locked-received"]]; + else + [self.navBarEncryptToggleButton setImage:[UIImage imageNamed:@"745-unlocked"]]; + //disable encryption button on unsupported muc types + if(self.contact.isMuc && [self.contact.mucType isEqualToString:kMucTypeGroup] == NO) + [self.navBarEncryptToggleButton setEnabled:NO]; + //disable encryption button for special jids + if([HelperTools isContactBlacklistedForEncryption:self.contact]) + [self.navBarEncryptToggleButton setEnabled:NO]; +} + +-(void) handleContactRemoved:(NSNotification*) notification +{ + MLContact* contact = [notification.userInfo objectForKey:@"contact"]; + if(self.contact && [self.contact isEqualToContact:contact]) + { + dispatch_async(dispatch_get_main_queue(), ^{ + DDLogInfo(@"Closing chat view, contact was removed..."); + [self.navigationController popToRootViewControllerAnimated:YES]; + }); + } +} + +-(void) refreshContact:(NSNotification*) notification +{ + @synchronized(_localMLContactCache) { + [_localMLContactCache removeAllObjects]; + } + MLContact* contact = [notification.userInfo objectForKey:@"contact"]; + if(self.contact && [self.contact isEqualToContact:contact]) + [self updateUIElements]; +} + +-(void) updateUIElements +{ + if(self.contact.accountID == nil) + return; + + NSString* jidLabelText = nil; + BOOL sendButtonEnabled = NO; + + NSString* contactDisplayName = self.contact.contactDisplayName; + if(!contactDisplayName) + contactDisplayName = @""; + + //send button is always enabled, except if the account is permanently disabled + sendButtonEnabled = YES; + if(![[DataLayer sharedInstance] isAccountEnabled:self.contact.accountID]) + sendButtonEnabled = NO; + + jidLabelText = contactDisplayName; + + if(self.contact.isMuc) + { + NSArray* members = [[DataLayer sharedInstance] getMembersAndParticipantsOfMuc:self.contact.contactJid forAccountID:self.xmppAccount.accountID]; + NSInteger membercount = members.count; + if([self.contact.mucType isEqualToString:kMucTypeGroup]) + { + NSMutableSet* memberSet = [NSMutableSet new]; + for(NSDictionary* entry in members) + { + if(entry[@"participant_jid"] != nil) + [memberSet addObject:entry[@"participant_jid"]]; + if(entry[@"member_jid"] != nil) + [memberSet addObject:entry[@"member_jid"]]; + } + membercount = memberSet.count; + } + if(membercount > 1) + jidLabelText = [NSString stringWithFormat:@"%@ (%ld)", contactDisplayName, membercount - 1]; //don't count ourselves + } + // change text values + dispatch_async(dispatch_get_main_queue(), ^{ + self.navBarContactJid.text = jidLabelText; + [self.customHeader setAccessibilityLabel:jidLabelText]; + self.sendButton.enabled = sendButtonEnabled; + [[MLImageManager sharedInstance] getIconForContact:self.contact withCompletion:^(UIImage *image) { + self.navBarIcon.image = image; + }]; + + [self updateCallButtonImage]; + }); +} + +-(void) updateUIElementsOnAccountChange:(NSNotification* _Nullable) notification +{ + if(notification) + { + NSDictionary* userInfo = notification.userInfo; + // Check if all objects of the notification are present + NSString* accountID = [userInfo objectForKey:kAccountID]; + NSNumber* accountState = [userInfo objectForKey:kAccountState]; + + // Only parse account changes for our current opened account + if(accountID.intValue != self.xmppAccount.accountID.intValue) + return; + + if(accountID && accountState) + [self updateUIElements]; + } + else + { + [self updateUIElements]; + } +} + +-(void) stopLastInteractionTimer +{ + @synchronized(self) { + if(_cancelLastInteractionTimer) + _cancelLastInteractionTimer(); + _cancelLastInteractionTimer = nil; + } +} + +-(void) updateTypingTime:(NSDate* _Nullable) lastInteractionDate +{ + DDLogVerbose(@"LastInteraction updateTime() called: %@", lastInteractionDate); + NSString* lastInteractionString = @""; //unknown last interaction because not supported by any remote resource + if(lastInteractionDate != nil) + lastInteractionString = [HelperTools formatLastInteraction:lastInteractionDate]; + dispatch_async(dispatch_get_main_queue(), ^{ + self.navBarLastInteraction.text = lastInteractionString; + }); + + @synchronized(self) { + [self stopLastInteractionTimer]; + // this timer will be called only if needed and makes sure the "last active: xx minutes ago" text gets updated every minute + if(lastInteractionDate != nil && lastInteractionDate.timeIntervalSince1970 > 0) + _cancelLastInteractionTimer = createTimer(60.0, ^{ + [self updateTypingTime:lastInteractionDate]; + }); + } +} + +-(void) updateNavBarLastInteractionLabel:(NSNotification*) notification +{ + NSDate* lastInteractionDate = nil; + NSString* jid = self.contact.contactJid; + // use supplied data from notification... + if(notification) + { + NSDictionary* data = notification.userInfo; + NSString* notifcationAccountID = data[@"accountID"]; + if(![jid isEqualToString:data[@"jid"]] || self.contact.accountID.intValue != notifcationAccountID.intValue) + return; // ignore other accounts or contacts + if([data[@"isTyping"] boolValue] == YES) + { + [self stopLastInteractionTimer]; + dispatch_async(dispatch_get_main_queue(), ^{ + self.navBarLastInteraction.text = NSLocalizedString(@"Typing...", @""); + }); + return; + } + // this is nil for a "not typing" (aka typing ended) notification or if no "urn:xmpp:idle:1" is supported by any devices of this contact + lastInteractionDate = nilExtractor(data[@"lastInteraction"]); + } + // ...or load the latest interaction timestamp from db + else + // this is nil if no "urn:xmpp:idle:1" is supported by any devices of this contact + lastInteractionDate = self.contact.lastInteractionTime; + + // make timestamp human readable (lastInteractionDate will be captured by this block and automatically used by our timer) + [self updateTypingTime:lastInteractionDate]; +} + +-(void) viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + //throw on empty contacts + MLAssert(self.contact.contactJid != nil, @"can not open chat for empty contact jid"); + MLAssert(self.contact.accountID != nil, @"can not open chat for empty account id"); + + NSNotificationCenter* nc = [NSNotificationCenter defaultCenter]; + [nc addObserver:self selector:@selector(handleNewMessage:) name:kMonalNewMessageNotice object:nil]; + [nc addObserver:self selector:@selector(handleDeletedMessage:) name:kMonalDeletedMessageNotice object:nil]; + [nc addObserver:self selector:@selector(handleSentMessage:) name:kMonalSentMessageNotice object:nil]; + [nc addObserver:self selector:@selector(handleMessageError:) name:kMonalMessageErrorNotice object:nil]; + [nc addObserver:self selector:@selector(handleOmemoFetchStateUpdate:) name:kMonalOmemoFetchingStateUpdate object:nil]; + + [nc addObserver:self selector:@selector(dismissKeyboard:) name:UIApplicationDidEnterBackgroundNotification object:nil]; + [nc addObserver:self selector:@selector(handleForeGround) name:kMonalRefresh object:nil]; + + [nc addObserver:self selector:@selector(keyboardDidShow:) name:UIKeyboardDidShowNotification object:nil]; + [nc addObserver:self selector:@selector(keyboardDidHide:) name:UIKeyboardDidHideNotification object:nil]; + [nc addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil]; + [nc addObserver:self selector:@selector(keyboardWillDisappear:) name:UIKeyboardWillHideNotification object:nil]; + + [nc addObserver:self selector:@selector(handleReceivedMessage:) name:kMonalMessageReceivedNotice object:nil]; + [nc addObserver:self selector:@selector(handleDisplayedMessage:) name:kMonalMessageDisplayedNotice object:nil]; + [nc addObserver:self selector:@selector(handleFiletransferMessageUpdate:) name:kMonalMessageFiletransferUpdateNotice object:nil]; + + [nc addObserver:self selector:@selector(refreshContact:) name:kMonalContactRefresh object:nil]; + [nc addObserver:self selector:@selector(handleContactRemoved:) name:kMonalContactRemoved object:nil]; + [nc addObserver:self selector:@selector(updateUIElementsOnAccountChange:) name:kMonalAccountStatusChanged object:nil]; + [nc addObserver:self selector:@selector(updateNavBarLastInteractionLabel:) name:kMonalLastInteractionUpdatedNotice object:nil]; + + [nc addObserver:self selector:@selector(handleBackgroundChanged) name:kMonalBackgroundChanged object:nil]; + + [nc addObserver:self selector:@selector(updateCallButtonImage) name:kMonalCallAdded object:nil]; + [nc addObserver:self selector:@selector(updateCallButtonImage) name:kMonalCallRemoved object:nil]; + + self.viewDidAppear = NO; + self.viewIsScrolling = YES; + //stop editing (if there is some) + [self stopEditing]; + self.xmppAccount = self.contact.account; + if(!self.xmppAccount) DDLogDebug(@"Disabled account detected"); + + [MLNotificationManager sharedInstance].currentContact = self.contact; + + [self handleForeGround]; + [self updateUIElements]; + [self updateNavBarLastInteractionLabel:nil]; + [self displayEncryptionStateInUI]; + + [self handleBackgroundChanged]; + + self.placeHolderText.text = [NSString stringWithFormat:NSLocalizedString(@"Message from %@", @""), self.jid]; + // Load message draft from db + NSString* messageDraft = [[DataLayer sharedInstance] loadMessageDraft:self.contact.contactJid forAccount:self.contact.accountID]; + if(messageDraft && [messageDraft length] > 0) { + dispatch_async(dispatch_get_main_queue(), ^{ + self.chatInput.text = messageDraft; + self.placeHolderText.hidden = YES; + }); + } + self.hardwareKeyboardPresent = YES; //default to YES and when keybaord will appears is called, this may be set to NO + [self setSendButtonIconWithTextLength:[self.chatInput.text length]]; + + // Set correct chatInput height constraints + [self setChatInputHeightConstraints:self.hardwareKeyboardPresent]; + + [self tempfreezeAutoloading]; + + [self.contact addObserver:self forKeyPath:@"isEncrypted" options:NSKeyValueObservingOptionNew context:nil]; + + [self scrollToBottomAnimated:NO]; +} + + +-(void) viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + [self checkOmemoSupportWithAlert:NO]; + + [self refreshCounter]; + + //init the floating last message button + [self initLastMsgButton]; + + self.viewDidAppear = YES; + + [self initSearchViewControler]; +} + +-(void) viewWillDisappear:(BOOL)animated +{ + //stop editing (if there is some) + [self stopEditing]; + + //stop audio recording, if currently running + if(self->_isRecording) + { + [[MLAudioRecoderManager sharedInstance] stop:NO]; + self->_isRecording = NO; + } + + NSNotificationCenter* nc = [NSNotificationCenter defaultCenter]; + [nc removeObserver:self]; + @try + { + [self.contact removeObserver:self forKeyPath:@"isEncrypted"]; + } + @catch(id theException) + { + //do nothing + } + + // Save message draft + BOOL success = [self saveMessageDraft]; + if(success) { + // Update status message for contact to show current draft + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; + } + [super viewWillDisappear:animated]; + [MLNotificationManager sharedInstance].currentContact = nil; + + [self sendChatState:NO]; + [self stopLastInteractionTimer]; + + [_lastMsgButton removeFromSuperview]; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + if(self.messageTable.contentSize.height > self.messageTable.bounds.size.height) + [self.messageTable setContentOffset:CGPointMake(0, self.messageTable.contentSize.height - self.messageTable.bounds.size.height) animated:NO]; +} + +-(BOOL) saveMessageDraft +{ + // Save message draft + return [[DataLayer sharedInstance] saveMessageDraft:self.contact.contactJid forAccount:self.contact.accountID withComment:self.chatInput.text]; +} + +-(void) dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + @try + { + [self.contact removeObserver:self forKeyPath:@"isEncrypted"]; + } + @catch(id theException) + { + //do nothing + } + [self stopLastInteractionTimer]; +} + +-(void) handleBackgroundChanged +{ + dispatch_async(dispatch_get_main_queue(), ^{ + DDLogVerbose(@"Loading background image for %@", self.contact); + self.backgroundImage.image = [[MLImageManager sharedInstance] getBackgroundFor:self.contact]; + //use default background if this contact does not have its own + if(self.backgroundImage.image == nil) + self.backgroundImage.image = [[MLImageManager sharedInstance] getBackgroundFor:nil]; + self.backgroundImage.hidden = self.backgroundImage.image == nil; + DDLogVerbose(@"Background is now: %@", self.backgroundImage.image); + }); +} + +#pragma mark rotation +-(void) viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator +{ + [self stopEditing]; + [self.chatInput resignFirstResponder]; +} + +#pragma mark gestures + +-(IBAction)dismissKeyboard:(id)sender +{ + [self stopEditing]; + [self saveMessageDraft]; + [self.chatInput resignFirstResponder]; + [self sendChatState:NO]; +} + +#pragma mark message signals + +-(void) refreshCounter +{ + if(self.navigationController.topViewController == self) + { + if(![self.contact isEqualToContact:[MLNotificationManager sharedInstance].currentContact]) + return; + + if(![HelperTools isNotInFocus]) + { + //don't block the main thread while writing to the db (another thread could hold a write transaction already, slowing down the main thread) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + //get list of unread messages + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:self.contact.contactJid andAccount:self.contact.accountID tillStanzaId:nil wasOutgoing:NO]; + + //publish MDS display marker and optionally send displayed marker for last unread message (XEP-0333) + DDLogDebug(@"Sending MDS (and possibly XEP-0333 displayed marker) for messages: %@", unread); + [self.xmppAccount sendDisplayMarkerForMessages:unread]; + + //now switch back to the main thread, we are reading only (and self.contact should only be accessed from the main thread) + dispatch_async(dispatch_get_main_queue(), ^{ + //remove notifications of all read messages (this will cause the MLNotificationManager to update the app badge, too) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:self.xmppAccount userInfo:@{@"messagesArray":unread}]; + + // update unread counter + [self.contact updateUnreadCount]; + + //refresh contact in active contacts view + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; + }); + }); + + } + else + DDLogDebug(@"Not marking messages as read because we are still in background: %@ notInFokus: %@", bool2str([HelperTools isInBackground]), bool2str([HelperTools isNotInFocus])); + } +} + +-(void) refreshData +{ + if(!self.contact.contactJid) + return; + + NSMutableArray* messages = [[DataLayer sharedInstance] messagesForContact:self.contact.contactJid forAccount: self.contact.accountID]; + NSNumber* unreadMsgCnt = [[DataLayer sharedInstance] countUserUnreadMessages:self.contact.contactJid forAccount: self.contact.accountID]; + + if([unreadMsgCnt integerValue] == 0) + self->_firstmsg = YES; + + if(!self.jid) + return; + + //TODO: use a factory method for this!! + MLMessage* unreadStatus = [MLMessage new]; + unreadStatus.messageType = kMessageTypeStatus; + unreadStatus.messageText = NSLocalizedString(@"Unread Messages Below", @""); + unreadStatus.actualFrom = self.jid; + unreadStatus.isMuc = self.contact.isMuc; + + NSInteger unreadPos = (NSInteger)messages.count - 1; + while(unreadPos >= 0) + { + MLMessage* row = [messages objectAtIndex:unreadPos]; + if(!row.unread) + { + unreadPos++; //move back down one + break; + } + unreadPos--; //move up the list + } + + if(unreadPos <= (NSInteger)messages.count - 1 && unreadPos > 0) { + [messages insertObject:unreadStatus atIndex:unreadPos]; + } + + self.messageList = messages; + [self doSetNotLoadingHistory]; + [self refreshCounter]; +} + +#pragma mark - textview +-(void) sendMessage:(NSString*) messageText withType:(NSString*) messageType +{ + [self sendMessage:messageText andMessageID:nil withType:messageType]; +} + +-(void) sendMessage:(nonnull NSString*) messageText andMessageID:(NSString*) messageID withType:(NSString*) messageType +{ + DDLogVerbose(@"Sending message"); + NSString* newMessageID = messageID ? messageID : [[NSUUID UUID] UUIDString]; + //dont readd it, use the exisitng + NSDictionary* accountDict = [[DataLayer sharedInstance] detailsForAccount:self.contact.accountID]; + if(accountDict == nil) + { + DDLogError(@"AccountID %@ not found!", self.contact.accountID); + return; + } + if(self.contact.contactJid == nil || [[DataLayer sharedInstance] isContactInList:self.contact.contactJid forAccount:self.contact.accountID] == NO) + { + DDLogError(@"Can not send message to unkown contact %@ on accountID %@ - GUI Error", self.contact.contactJid, self.contact.accountID); + return; + } + if(!messageID && !messageType) { + DDLogError(@"message id and type both cant be empty"); + return; + } + + if(!messageID) + { + [self addMessageto:self.contact.contactJid withMessage:messageText andId:newMessageID messageType:messageType mimeType:nil size:nil]; + [[MLXMPPManager sharedInstance] sendMessage:messageText toContact:self.contact isEncrypted:self.contact.isEncrypted isUpload:NO messageId:newMessageID + withCompletionHandler:nil]; + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; + } + else + { + //clean error because this seems to be a retry (to be filled again, if error persists) + [[DataLayer sharedInstance] clearErrorOfMessageId:newMessageID]; + for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) + { + // find msg that should be updated + MLMessage* msg = [self.messageList objectAtIndex:(msgIdx - 1)]; + if([msg.messageId isEqualToString:newMessageID]) + { + msg.errorType = @""; + msg.errorReason = @""; + } + } + [[MLXMPPManager sharedInstance] + sendMessage:messageText + toContact:self.contact + isEncrypted:self.contact.isEncrypted + isUpload:NO + messageId:newMessageID + withCompletionHandler:nil + ]; + } + + [[MLNotificationQueue currentQueue] postNotificationName:kMLMessageSentToContact object:self userInfo:@{@"contact":self.contact}]; +} + +-(void) sendChatState:(BOOL) isTyping +{ + if(!self.sendButton.enabled) + { + DDLogWarn(@"Account disabled, ignoring chatstate update"); + return; + } + + // Do not send when the user disabled the feature + if(![[HelperTools defaultsDB] boolForKey: @"SendLastChatState"]) + return; + + if(isTyping != _isTyping) //changed state? --> send typing notification + { + DDLogVerbose(@"Sending chatstate isTyping=%@", bool2str(isTyping)); + [[MLXMPPManager sharedInstance] sendChatState:isTyping toContact:self.contact]; + } + + //set internal state + _isTyping = isTyping; + + //cancel old timer if existing + if(_cancelTypingNotification) + _cancelTypingNotification(); + + //start new timer if we are currently typing + if(isTyping) + _cancelTypingNotification = createTimer(5.0, (^{ + //no typing interaction in 5 seconds? --> send out active chatstate (e.g. typing ended) + if(self->_isTyping) + { + self->_isTyping = NO; + DDLogVerbose(@"Sending chatstate isTyping=NO"); + [[MLXMPPManager sharedInstance] sendChatState:NO toContact:self.contact]; + } + })); +} + +-(void) resignTextView +{ + [self tempfreezeAutoloading]; + + // Trim leading spaces + NSString* cleanString = [self.chatInput.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + // Only send msg that have at least one character + if(cleanString.length > 0) + { + // Reset chatInput -> remove draft from db so that macOS will show the newly sent message + [self.chatInput setText:@""]; + [self saveMessageDraft]; + + [self setSendButtonIconWithTextLength:0]; + + if(self.editingCallback) + self.editingCallback(cleanString); + else + { + // Send trimmed message + NSString* lowercaseCleanString = [cleanString lowercaseString]; + if([lowercaseCleanString rangeOfString:@" "].location == NSNotFound && [lowercaseCleanString hasPrefix:@"https://"]) + [self sendMessage:cleanString withType:kMessageTypeUrl]; + else + [self sendMessage:cleanString withType:kMessageTypeText]; + } + } + [self sendChatState:NO]; + [self emptyUploadQueue]; +} + +-(IBAction) sendMessageText:(id)sender +{ + [self resignTextView]; +} + +-(void) handleRecord:(BOOL) granted +{ + if(granted) + { + if(!self->_isRecording) + { + DDLogInfo(@"Starting to record audio..."); + [[MLAudioRecoderManager sharedInstance] setRecoderManagerDelegate:self]; + [[MLAudioRecoderManager sharedInstance] start]; + self->_isRecording = YES; + } + else + { + DDLogInfo(@"Stopping audio recording..."); + [[MLAudioRecoderManager sharedInstance] stop:YES]; + self->_isRecording = NO; + } + } + else + { + dispatch_async(dispatch_get_main_queue(), ^{ + UIAlertController *messageAlert =[UIAlertController alertControllerWithTitle:NSLocalizedString(@"Please Allow Audio Access", @"") message:NSLocalizedString(@"If you want to use audio message you will need to allow access in Settings-> Privacy-> Microphone.", @"") preferredStyle:UIAlertControllerStyleAlert]; + + UIAlertAction *closeAction =[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { + + }]; + + [messageAlert addAction:closeAction]; + [self presentViewController:messageAlert animated:YES completion:nil]; + }); + } +} + +-(IBAction) record:(id) sender +{ + dispatch_async(dispatch_get_main_queue(), ^{ + DDLogInfo(@"Record button pressed..."); + if(@available(iOS 17, macCatalyst 17.0, *)) { + [AVAudioApplication requestRecordPermissionWithCompletionHandler:^(BOOL granted) { + [self handleRecord:granted]; + }]; + } else { + [[AVAudioSession sharedInstance] requestRecordPermission:^(BOOL granted) { + [self handleRecord:granted]; + }]; + } + }); +} + +-(void) recordMessageAudio:(UILongPressGestureRecognizer*) gestureRecognizer +{ + DDLogInfo(@"Gesture recognizer called..."); + if(gestureRecognizer.state == UIGestureRecognizerStateBegan && _isRecording) + { + DDLogInfo(@"Long press began, aborting audio recording..."); + [[MLAudioRecoderManager sharedInstance] stop:NO]; + _isRecording = NO; + } +} + +-(BOOL) shouldPerformSegueWithIdentifier:(NSString*) identifier sender:(id) sender +{ + return YES; +} + +-(void) performSegueWithIdentifier:(NSString*) identifier sender:(id) sender +{ + //this is needed to prevent segues invoked programmatically + if([self shouldPerformSegueWithIdentifier:identifier sender:sender] == NO) + return; + if([identifier isEqualToString:@"showDetails"]) + { + UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails: self.contact]; + [self presentViewController:detailsViewController animated:YES completion:^{}]; + return; + } + [super performSegueWithIdentifier:identifier sender:sender]; +} + + +-(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender +{ + [self sendChatState:NO]; +} + + +#pragma mark - doc picker +-(IBAction) attachfile:(id) sender +{ + [self stopEditing]; + [self.chatInput resignFirstResponder]; + + [self presentViewController:self.filePicker animated:YES completion:nil]; + + return; +} + +-(void) documentPicker:(UIDocumentPickerViewController*) controller didPickDocumentsAtURLs:(NSArray*) urls +{ + DDLogDebug(@"Picked files at urls: %@", urls); + if(urls.count == 0) + return; + for(NSURL* url in urls) + { + [url startAccessingSecurityScopedResource]; //call to stopAccessingSecurityScopedResource will be done in addUploadItemPreviewForItem + [HelperTools addUploadItemPreviewForItem:url provider:nil andPayload:[@{ + @"type": @"file", + @"filename": [url lastPathComponent], + @"data": [MLFiletransfer prepareFileUpload:url], + } mutableCopy] withCompletionHandler:^(NSMutableDictionary* payload) { + [self addToUIQueue:@[payload]]; + }]; + } +} + +#pragma mark - location delegate +-(void) locationManagerDidChangeAuthorization:(CLLocationManager*) manager +{ + CLAuthorizationStatus gpsStatus = [manager authorizationStatus]; + if(gpsStatus == kCLAuthorizationStatusAuthorizedAlways || gpsStatus == kCLAuthorizationStatusAuthorizedWhenInUse) + { + if(self.sendLocation) + { + self.sendLocation = NO; + [self.locationManager requestLocation]; + } + } + else if(gpsStatus == kCLAuthorizationStatusDenied || gpsStatus == kCLAuthorizationStatusRestricted) + { + // Display warning + UIAlertController* gpsWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Missing permission", @"") + message:NSLocalizedString(@"You did not grant Monal to access your location.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [gpsWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Ok", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* _Nonnull action) { + [gpsWarning dismissViewControllerAnimated:YES completion:nil]; + }]]; + [gpsWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Settings", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* _Nonnull action) { + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil]; + }]]; + [self presentViewController:gpsWarning animated:YES completion:nil]; + } +} + +-(void) locationManager:(CLLocationManager*) manager didUpdateLocations:(NSArray*) locations +{ + [self.locationManager stopUpdatingLocation]; + + // Only send geo message if gpsHUD is visible + if(self.gpsHUD.hidden == YES) { + return; + } + + // Check last location + CLLocation* gpsLoc = [locations lastObject]; + if(gpsLoc == nil) { + return; + } + self.gpsHUD.hidden = YES; + // Send location + [self sendMessage:[NSString stringWithFormat:@"geo:%f,%f", gpsLoc.coordinate.latitude, gpsLoc.coordinate.longitude] withType:kMessageTypeGeo]; +} + +- (void) locationManager:(CLLocationManager*) manager didFailWithError:(NSError*) error +{ + DDLogError(@"Error while fetching location %@", error); +} + +-(void) makeLocationManager +{ + if(self.locationManager == nil) + { + self.locationManager = [CLLocationManager new]; + self.locationManager.desiredAccuracy = kCLLocationAccuracyBest; + self.locationManager.delegate = self; + } +} + +-(void) displayGPSHUD +{ + // Setup HUD + if(!self.gpsHUD) { + self.gpsHUD = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + self.gpsHUD.removeFromSuperViewOnHide=NO; + self.gpsHUD.label.text = NSLocalizedString(@"GPS", @""); + self.gpsHUD.detailsLabel.text = NSLocalizedString(@"Waiting for GPS signal", @""); + } + // Display HUD + self.gpsHUD.hidden = NO; + + // Trigger warning when no gps location was received + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 4 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + if(self.gpsHUD.hidden == NO) { + // Stop locationManager & hide gpsHUD screen + [self.locationManager stopUpdatingLocation]; + self.gpsHUD.hidden = YES; + + // Display warning + UIAlertController* gpsWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"No GPS location received", @"") + message:NSLocalizedString(@"Monal did not received a gps location. Please try again later.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [gpsWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Ok", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* _Nonnull action) { + [gpsWarning dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:gpsWarning animated:YES completion:nil]; + } + }); +} + +-(PHPickerViewController*) generatePHPickerViewController +{ + PHPickerConfiguration* phConf = [PHPickerConfiguration new]; + phConf.selectionLimit = 0; + phConf.filter = [PHPickerFilter anyFilterMatchingSubfilters:@[PHPickerFilter.imagesFilter, PHPickerFilter.videosFilter]]; + PHPickerViewController* picker = [[PHPickerViewController alloc] initWithConfiguration:phConf]; + picker.delegate = self; + return picker; +} + +#pragma mark - attachment picker + +-(void) showCameraPermissionWarning +{ + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Camera permissions missing", @"Camera permissions missing warning") message:NSLocalizedString(@"Monal is not allowed to access the camera", @"Camera permissions missing warning") preferredStyle:UIAlertControllerStyleAlert]; + + UIAlertAction* defaultAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action) {}]; + + UIAlertAction* monalIosSettings = [UIAlertAction actionWithTitle:NSLocalizedString(@"Settings", @"Camera permissions missing warning") style:UIAlertActionStyleDefault handler:^(UIAlertAction* _Nonnull action) { + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil]; + }]; + + [alert addAction:defaultAction]; + [alert addAction:monalIosSettings]; + [self presentViewController:alert animated:YES completion:nil]; +} + +-(IBAction) attach:(id) sender +{ + [self stopEditing]; + [self.chatInput resignFirstResponder]; + + UIAlertController* actionControll = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Select Action", @"") + message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + // Check for http upload support + if(!self.xmppAccount.connectionProperties.supportsHTTPUpload) + { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", @"") + message:NSLocalizedString(@"This server does not appear to support HTTP file uploads (XEP-0363). Please ask the administrator to enable it.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; + + return; + } else { +#if TARGET_OS_MACCATALYST + UIAlertAction* fileAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Files", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [self attachfile:sender]; + }]; + + [fileAction setValue:[[UIImage systemImageNamed:@"doc"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; + [actionControll addAction:fileAction]; +#else + UIImagePickerController* mediaPicker = [UIImagePickerController new]; + mediaPicker.delegate = self; + + UIAlertAction* cameraAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Camera", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* _Nonnull action __unused) { + @try { + mediaPicker.sourceType = UIImagePickerControllerSourceTypeCamera; + mediaPicker.mediaTypes = @[UTTypeImage.identifier, UTTypeMovie.identifier]; + + switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) + { + case AVAuthorizationStatusAuthorized: + { + dispatch_async(dispatch_get_main_queue(), ^{ + [self presentViewController:mediaPicker animated:YES completion:nil]; + }); + break; + } + case AVAuthorizationStatusNotDetermined: + { + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) + { + if(granted == YES) + { + dispatch_async(dispatch_get_main_queue(), ^{ + [self presentViewController:mediaPicker animated:YES completion:nil]; + }); + } + else + DDLogWarn(@"Camera access not granted. AV Permissions now set to denied"); + }]; + break; + } + case AVAuthorizationStatusDenied: + case AVAuthorizationStatusRestricted: + { + DDLogWarn(@"Camera access denied"); + [self showCameraPermissionWarning]; + break; + } + } + } @catch(id ex) { + DDLogError(@"catched exception while opening camera: %@", ex); + [self showCameraPermissionWarning]; + } + }]; + + UIAlertAction* photosAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Photos", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action __unused) { + [self presentViewController:[self generatePHPickerViewController] animated:YES completion:nil]; + }]; + + UIAlertAction* fileAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"File", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [self attachfile:sender]; + }]; + + // Set image + [cameraAction setValue:[[UIImage systemImageNamed:@"camera"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; + [photosAction setValue:[[UIImage systemImageNamed:@"photo"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; + [fileAction setValue:[[UIImage systemImageNamed:@"doc"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; + + [actionControll addAction:cameraAction]; + [actionControll addAction:photosAction]; + [actionControll addAction:fileAction]; +#endif + } + + UIAlertAction* gpsAlert = [UIAlertAction actionWithTitle:NSLocalizedString(@"Send Location", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* _Nonnull action) { + // GPS + CLLocationManager* gpsManager = [CLLocationManager new]; + CLAuthorizationStatus gpsStatus = [gpsManager authorizationStatus]; + if(gpsStatus == kCLAuthorizationStatusAuthorizedAlways || gpsStatus == kCLAuthorizationStatusAuthorizedWhenInUse) { + [self displayGPSHUD]; + [self makeLocationManager]; + [self.locationManager startUpdatingLocation]; + } + else if(gpsStatus == kCLAuthorizationStatusNotDetermined || gpsStatus == kCLAuthorizationStatusRestricted) + { + [self makeLocationManager]; + self.sendLocation = YES; + [self.locationManager requestWhenInUseAuthorization]; + } + else + { + UIAlertController *permissionAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Location Access Needed", @"") + message:NSLocalizedString(@"Monal does not have access to your location. Please update the location access in your device's Privacy Settings.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [self presentViewController:permissionAlert animated:YES completion:nil]; + [permissionAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* _Nonnull action __unused) { + [permissionAlert dismissViewControllerAnimated:YES completion:nil]; + }]]; + } + }]; + + // Set image + [gpsAlert setValue:[[UIImage systemImageNamed:@"location"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; + [actionControll addAction:gpsAlert]; + [actionControll addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* _Nonnull action) { + [actionControll dismissViewControllerAnimated:YES completion:nil]; + }]]; + + actionControll.popoverPresentationController.sourceView = sender; + [self presentViewController:actionControll animated:YES completion:nil]; +} + +-(void) picker:(PHPickerViewController*) picker didFinishPicking:(NSArray*) results +{ + [self dismissViewControllerAnimated:YES completion:nil]; + for(PHPickerResult* userSelection in results) + { + DDLogDebug(@"Handling asset with identifier: %@", userSelection.assetIdentifier); + NSItemProvider* provider = userSelection.itemProvider; + MLAssert(provider != nil, @"Expected a NSItemProvider"); + [HelperTools handleUploadItemProvider:provider withCompletionHandler:^(NSMutableDictionary* payload) { + dispatch_async(dispatch_get_main_queue(), ^{ + if(payload == nil || payload[@"error"] != nil) + { + DDLogError(@"Could not save payload for sending: %@", payload[@"error"]); + NSString* message = NSLocalizedString(@"Monal was not able to send your attachment!", @""); + if(payload[@"error"] != nil) + message = [NSString stringWithFormat:NSLocalizedString(@"Monal was not able to send your attachment: %@", @""), [payload[@"error"] localizedDescription]]; + UIAlertController* unknownItemWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not send", @"") + message:message preferredStyle:UIAlertControllerStyleAlert]; + [unknownItemWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Abort", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { + [unknownItemWarning dismissViewControllerAnimated:YES completion:nil]; + [self.extensionContext completeRequestReturningItems:@[] completionHandler:nil]; + }]]; + [self presentViewController:unknownItemWarning animated:YES completion:nil]; + } + else + { + DDLogDebug(@"Adding payload to UI upload queue: %@", payload); + [self addToUIQueue:@[payload]]; + } + }); + }]; + } +} + +-(void) imagePickerController:(UIImagePickerController*) picker didFinishPickingMediaWithInfo:(NSDictionary*) info +{ + [self dismissViewControllerAnimated:YES completion:nil]; + if(info[UIImagePickerControllerMediaType] == nil) + return; + + if([info[UIImagePickerControllerMediaType] isEqualToString:UTTypeImage.identifier]) + { + UIImage* selectedImage = info[UIImagePickerControllerEditedImage]; + if(!selectedImage) + selectedImage = info[UIImagePickerControllerOriginalImage]; + [self addToUIQueue:@[@{ + @"type": @"image", + @"preview": selectedImage, + @"data": [MLFiletransfer prepareUIImageUpload:selectedImage], + }]]; + } + else if([info[UIImagePickerControllerMediaType] isEqualToString:UTTypeMovie.identifier]) + { + NSURL* url = info[UIImagePickerControllerMediaURL]; + [url startAccessingSecurityScopedResource]; //call to stopAccessingSecurityScopedResource will be done in addUploadItemPreviewForItem + [HelperTools addUploadItemPreviewForItem:url provider:nil andPayload:[@{ + @"type": @"audiovisual", + @"filename": [url lastPathComponent], + @"data": [MLFiletransfer prepareFileUpload:url], + } mutableCopy] withCompletionHandler:^(NSMutableDictionary* payload) { + [self addToUIQueue:@[payload]]; + }]; + } + else + { + DDLogWarn(@"Created MediaType: %@ without handler", info[UIImagePickerControllerMediaType]); + unreachable(); + } +} + +-(void) imagePickerControllerDidCancel:(UIImagePickerController*) picker +{ + [self dismissViewControllerAnimated:YES completion:nil]; +} + +#pragma mark - handling notfications + +-(void) reloadTable +{ + if(self.messageTable.hasUncommittedUpdates) + return; + [self.messageTable reloadData]; +} + +//only for messages going out +-(MLMessage* _Nullable) addMessageto:(NSString *)to withMessage:(nonnull NSString *) message andId:(nonnull NSString *) messageId messageType:(nonnull NSString *) messageType mimeType:(NSString *) mimeType size:(NSNumber *) size +{ + if(!self.jid || !message) + { + DDLogError(@"not ready to send messages"); + return nil; + } + + NSNumber* messageDBId = [[DataLayer sharedInstance] addMessageHistoryTo:to forAccount:self.contact.accountID withMessage:message actuallyFrom:(self.contact.isMuc ? self.contact.accountNickInGroup : self.jid) withId:messageId encrypted:self.contact.isEncrypted messageType:messageType mimeType:mimeType size:size]; + if(messageDBId != nil) + { + DDLogVerbose(@"added message"); + NSArray* msgList = [[DataLayer sharedInstance] messagesForHistoryIDs:@[messageDBId]]; + if(![msgList count]) + { + DDLogError(@"Could not find msg for history ID %@!", messageDBId); + return nil; + } + MLMessage* messageObj = msgList[0]; + + [self tempfreezeAutoloading]; + + //update message list in ui + dispatch_async(dispatch_get_main_queue(), ^{ + BOOL wasAtBottom = self->_isAtBottom; + [self.messageTable performBatchUpdates:^{ + if(!self.messageList) + self.messageList = [NSMutableArray new]; + [self.messageList addObject:messageObj]; + NSInteger bottom = [self.messageList count]-1; + if(bottom>=0) + { + NSIndexPath* path1 = [NSIndexPath indexPathForRow:bottom inSection:messagesSection]; + [self->_messageTable insertRowsAtIndexPaths:@[path1] + withRowAnimation:UITableViewRowAnimationNone]; + } + } completion:^(BOOL finished) { + if(wasAtBottom) + [self scrollToBottomAnimated:NO]; + }]; + }); + + // make sure its in active chats list + if(_firstmsg == YES) + { + [[DataLayer sharedInstance] addActiveBuddies:to forAccount:self.contact.accountID]; + _firstmsg = NO; + } + + //create and donate interaction to allow for share suggestions + [[MLNotificationManager sharedInstance] donateInteractionForOutgoingDBId:messageDBId]; + + return messageObj; + } + else + DDLogError(@"failed to add message to history db"); + return nil; +} + +-(void) handleNewMessage:(NSNotification *)notification +{ + DDLogVerbose(@"chat view got new message notice %@", notification.userInfo); + + MLMessage* message = [notification.userInfo objectForKey:@"message"]; + if(!message) + DDLogError(@"Notification without message"); + + if([message isEqualToContact:self.contact]) + { + dispatch_async(dispatch_get_main_queue(), ^{ + BOOL wasAtBottom = self->_isAtBottom; + + if(!self.messageList) + self.messageList = [NSMutableArray new]; + + //update already existent message + for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) + { + // find msg that should be updated + MLMessage* msgInList = [self.messageList objectAtIndex:(msgIdx - 1)]; + if([msgInList.messageDBId intValue] == [message.messageDBId intValue]) + { + //update message in our list + [msgInList updateWithMessage:message]; + + //update table entry + NSIndexPath* indexPath = [NSIndexPath indexPathForRow:(msgIdx - 1) inSection:messagesSection]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_messageTable beginUpdates]; + [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self->_messageTable endUpdates]; + }); + return; + } + } + [CATransaction begin]; + [self.messageList addObject:message]; //do not insert based on delay timestamp because that would make it possible to fake history entries + + [self->_messageTable beginUpdates]; + NSIndexPath *path1; + NSInteger bottom = self.messageList.count-1; + if(bottom >= 0) { + path1 = [NSIndexPath indexPathForRow:bottom inSection:messagesSection]; + [self->_messageTable insertRowsAtIndexPaths:@[path1] + withRowAnimation:UITableViewRowAnimationBottom]; + } + [self->_messageTable endUpdates]; + + + [CATransaction commit]; + + if (self.searchController.isActive) + { + [self doSetMsgPathIdx:bottom withDBId:message.messageDBId]; + [self.searchController getSearchData:self.self.searchController.searchBar.text]; + [self.searchController setResultToolBar]; + } + + [self refreshCounter]; + + if(wasAtBottom) + [self scrollToBottomAnimated:YES]; + }); + } +} + +-(void) handleDeletedMessage:(NSNotification*) notification +{ + NSDictionary* dic = notification.userInfo; + MLMessage* msg = dic[@"message"]; + + DDLogDebug(@"Got deleted message notice for history id %ld and message id %@", (long)[msg.messageDBId intValue], msg.messageId); + + for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) + { + // find msg that should be deleted + MLMessage* msgInList = [self.messageList objectAtIndex:(msgIdx - 1)]; + if([msgInList.messageDBId intValue] == [msg.messageDBId intValue]) + { + //update message in our list + [msgInList updateWithMessage:msg]; + + //update table entry + NSIndexPath* indexPath = [NSIndexPath indexPathForRow:(msgIdx - 1) inSection:messagesSection]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_messageTable beginUpdates]; + [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self->_messageTable endUpdates]; + }); + break; + } + } +} + +-(void) updateMsgState:(NSString *) messageId withEvent:(size_t) event withOptDic:(NSDictionary*) dic +{ + NSIndexPath* indexPath; + for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) + { + // find msg that should be updated + MLMessage* msg = [self.messageList objectAtIndex:(msgIdx - 1)]; + if([msg.messageId isEqualToString:messageId]) + { + // Set correct flags + if(event == msgSent) { + DDLogVerbose(@"got msgSent event for messageid: %@", messageId); + msg.hasBeenSent = YES; + } else if(event == msgRecevied) { + DDLogVerbose(@"got msgRecevied event for messageid: %@", messageId); + msg.hasBeenSent = YES; + msg.hasBeenReceived = YES; + } else if(event == msgDisplayed) { + DDLogVerbose(@"got msgDisplayed event for messageid: %@", messageId); + msg.hasBeenSent = YES; + msg.hasBeenReceived = YES; + msg.hasBeenDisplayed = YES; + } else if(event == msgErrorAfterSent) { + DDLogVerbose(@"got msgErrorAfterSent event for messageid: %@", messageId); + //we don't want to show errors if the message has been received at least once + if(!msg.hasBeenReceived) + { + msg.errorType = [dic objectForKey:@"errorType"]; + msg.errorReason = [dic objectForKey:@"errorReason"]; + + //ping muc to self-heal cases where we aren't joined anymore without noticing it + if(self.contact.isMuc) + [self.xmppAccount.mucProcessor ping:self.contact.contactJid]; + } + } + + indexPath = [NSIndexPath indexPathForRow:(msgIdx - 1) inSection:messagesSection]; + + //update table entry + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_messageTable beginUpdates]; + [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self->_messageTable endUpdates]; + }); + + break; + } + } +} + +-(void) handleSentMessage:(NSNotification*) notification +{ + XMPPMessage* msg = notification.userInfo[@"message"]; + if([msg.toUser isEqualToString:self.contact.contactJid]) + [self updateMsgState:msg.id withEvent:msgSent withOptDic:nil]; +} + +-(void) handleMessageError:(NSNotification*) notification +{ + NSDictionary* dic = notification.userInfo; + if([dic[@"jid"] isEqualToString:self.contact.contactJid]) + [self updateMsgState:[dic objectForKey:kMessageId] withEvent:msgErrorAfterSent withOptDic:dic]; +} + +-(void) handleReceivedMessage:(NSNotification*) notification +{ + NSDictionary *dic = notification.userInfo; + if([dic[@"jid"] isEqualToString:self.contact.contactJid]) + [self updateMsgState:[dic objectForKey:kMessageId] withEvent:msgRecevied withOptDic:nil]; +} + +-(void) handleDisplayedMessage:(NSNotification*) notification +{ + NSDictionary *dic = notification.userInfo; + if([dic[@"message"] isEqualToContact:self.contact]) + [self updateMsgState:[dic objectForKey:kMessageId] withEvent:msgDisplayed withOptDic:nil]; +} + +-(void) handleFiletransferMessageUpdate:(NSNotification*) notification +{ + NSDictionary* dic = notification.userInfo; + MLMessage* msg = dic[@"message"]; + + DDLogDebug(@"Got filetransfer message update for history id %ld: %@ (%@)", (long)[msg.messageDBId intValue], msg.filetransferMimeType, msg.filetransferSize); + + NSIndexPath* indexPath; + for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) + { + // find msg that should be updated + MLMessage* msgInList = [self.messageList objectAtIndex:(msgIdx - 1)]; + if([msgInList.messageDBId intValue] == [msg.messageDBId intValue]) + { + //update message in our list (this will copy filetransferMimeType and filetransferSize fields) + [msgInList updateWithMessage:msg]; + + //update table entry + indexPath = [NSIndexPath indexPathForRow:(msgIdx - 1) inSection:messagesSection]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_messageTable beginUpdates]; + [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self->_messageTable endUpdates]; + }); + break; + } + } +} + +-(void) scrollToBottomIfNeeded +{ + if(_isAtBottom) + { + //DDLogVerbose(@"Scrolling to bottom because needed: %@", [NSThread callStackSymbols]); + [self scrollToBottomAnimated:NO]; + } +} + +-(void) scrollToBottomAnimated:(BOOL) animated +{ + if(self.messageList.count == 0) + return; + monal_void_block_t scrollBlock = ^{ + NSInteger bottom = [self.messageTable numberOfRowsInSection:messagesSection]; + if(bottom > 0) + { + DDLogVerbose(@"Scrolling to bottom(%@): %@", bool2str(animated), [NSThread callStackSymbols]); + NSIndexPath* path1 = [NSIndexPath indexPathForRow:bottom-1 inSection:messagesSection]; + [self.messageTable scrollToRowAtIndexPath:path1 atScrollPosition:UITableViewScrollPositionBottom animated:animated]; + self->_isAtBottom = YES; + } + [self refreshCounter]; + }; + if(animated) + { + DDLogVerbose(@"Registering timer for scrolling to bottom(%@): %@", bool2str(animated), [NSThread callStackSymbols]); + if(_scrollToBottomTimer) + _scrollToBottomTimer(); + _scrollToBottomTimer = createQueuedTimer(0.1, dispatch_get_main_queue(), (^{ + scrollBlock(); + })); + } + else + [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:scrollBlock]; +} + +#pragma mark - date time + +-(void) setupDateObjects +{ + self.destinationDateFormat = [NSDateFormatter new]; + [self.destinationDateFormat setLocale:[NSLocale currentLocale]]; + [self.destinationDateFormat setDoesRelativeDateFormatting:YES]; + + self.gregorian = [[NSCalendar alloc] + initWithCalendarIdentifier:NSCalendarIdentifierGregorian]; + + NSDate* now =[NSDate date]; + self.thisday = [self.gregorian components:NSCalendarUnitDay fromDate:now].day; + self.thismonth = [self.gregorian components:NSCalendarUnitMonth fromDate:now].month; + self.thisyear = [self.gregorian components:NSCalendarUnitYear fromDate:now].year; +} + + +-(NSString*) formattedDateWithSource:(NSDate *) sourceDate andPriorDate:(NSDate *) priorDate +{ + NSString* dateString; + if(sourceDate!=nil) + { + NSInteger msgday =[self.gregorian components:NSCalendarUnitDay fromDate:sourceDate].day; + NSInteger msgmonth=[self.gregorian components:NSCalendarUnitMonth fromDate:sourceDate].month; + NSInteger msgyear =[self.gregorian components:NSCalendarUnitYear fromDate:sourceDate].year; + + NSInteger priorDay = 0; + NSInteger priorMonth = 0; + NSInteger priorYear = 0; + + if(priorDate) { + priorDay = [self.gregorian components:NSCalendarUnitDay fromDate:priorDate].day; + priorMonth = [self.gregorian components:NSCalendarUnitMonth fromDate:priorDate].month; + priorYear = [self.gregorian components:NSCalendarUnitYear fromDate:priorDate].year; + } + + if (priorDate && ((priorDay != msgday) || (priorMonth != msgmonth) || (priorYear != msgyear)) ) + { + //divider, hide time + [self.destinationDateFormat setTimeStyle:NSDateFormatterNoStyle]; + // note: if it isnt the same day we want to show the full day + [self.destinationDateFormat setDateStyle:NSDateFormatterMediumStyle]; + dateString = [self.destinationDateFormat stringFromDate:sourceDate]; + } + } + return dateString; +} + +-(NSString*) formattedTimeStampWithSource:(NSDate *) sourceDate +{ + NSString* dateString; + if(sourceDate != nil) + { + [self.destinationDateFormat setDateStyle:NSDateFormatterNoStyle]; + [self.destinationDateFormat setTimeStyle:NSDateFormatterShortStyle]; + + dateString = [self.destinationDateFormat stringFromDate:sourceDate]; + } + return dateString; +} + + + +-(void) retry:(id) sender +{ + NSInteger msgHistoryID = ((UIButton*) sender).tag; + NSArray* msgArray = [[DataLayer sharedInstance] messagesForHistoryIDs:@[[NSNumber numberWithInteger:msgHistoryID]]]; + if(![msgArray count]) + { + DDLogError(@"Called retry for non existing message with history id %ld", (long)msgHistoryID); + return; + } + MLMessage* msg = msgArray[0]; + DDLogDebug(@"Called retry for message with history id %ld: %@", (long)msgHistoryID, msg); + + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Retry sending message?", @"") message:[NSString stringWithFormat:NSLocalizedString(@"This message failed to send (%@): %@", @""), msg.errorType, msg.errorReason] preferredStyle:UIAlertControllerStyleActionSheet]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Retry", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + [self sendMessage:msg.messageText andMessageID:msg.messageId withType:nil]; //type not needed for messages already in history db + //[self setMessageId:msg.messageId sent:YES]; // for the UI, db will be set in the notification + }]]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { + [self dismissViewControllerAnimated:YES completion:nil]; + }]]; + alert.popoverPresentationController.sourceView = sender; + + [self presentViewController:alert animated:YES completion:nil]; +} + +#pragma mark - tableview datasource + +-(NSInteger) numberOfSectionsInTableView:(UITableView *)tableView +{ + return chatViewControllerSectionCnt; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + switch (section) { + case reloadBoxSection: + return 1; + break; + case messagesSection: + { + return [self.messageList count]; + break; + } + default: + break; + } + return 0; +} + +-(nullable __kindof UITableViewCell*) messageTableCellWithIdentifier:(NSString*) identifier andInbound:(BOOL) inboundDirection fromTable:(UITableView*) tableView +{ + NSString* direction = @"In"; + if(!inboundDirection) + { + direction = @"Out"; + } + NSString* fullIdentifier = [NSString stringWithFormat:@"%@%@Cell", identifier, direction]; + return [tableView dequeueReusableCellWithIdentifier:fullIdentifier]; +} + +-(void) tableView:(UITableView*) tableView willDisplayCell:(nonnull UITableViewCell *)cell forRowAtIndexPath:(nonnull NSIndexPath *)indexPath +{ + if(indexPath.section == messagesSection && indexPath.row == 0) { + if(self.moreMessagesAvailable && !self.viewIsScrolling) { + self.viewIsScrolling = YES; //don't load the next messages immediately + [self loadOldMsgHistory]; + // Allow loading of more messages after a few seconds + createTimer(8, (^{ + self.viewIsScrolling = NO; + })); + } + } +} + +-(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath +{ + if(indexPath.section == reloadBoxSection) + { + MLReloadCell* cell = (MLReloadCell*)[tableView dequeueReusableCellWithIdentifier:@"reloadBox" forIndexPath:indexPath]; +#if TARGET_OS_MACCATALYST + // "Pull" could be a bit misleading on a mac + cell.reloadLabel.text = NSLocalizedString(@"Scroll down to load more messages", @"mac only string"); +#endif + + // Remove selection style (if cell is pressed) + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + MLBaseCell* cell; + + MLMessage* row; + if((NSUInteger)indexPath.row < self.messageList.count) { + row = [self.messageList objectAtIndex:indexPath.row]; + } else { + DDLogError(@"Attempt to access beyond bounds"); + } + + //cut text after kMonalChatMaxAllowedTextLen chars to make the message cell work properly (too big texts don't render the text in the cell at all) + NSString* messageText = row.messageText; + MLAssert(messageText != nil, @"Message text must not be nil!", (@{@"row": nilWrapper(row)})); + if([messageText length] > kMonalChatMaxAllowedTextLen) + messageText = [NSString stringWithFormat:@"%@\n[...]", [messageText substringToIndex:kMonalChatMaxAllowedTextLen]]; + BOOL inboundDir = row.inbound; + + if([row.messageType isEqualToString:kMessageTypeStatus]) + { + DDLogVerbose(@"got status cell cell: %@", messageText); + cell = [tableView dequeueReusableCellWithIdentifier:@"StatusCell"]; + cell.messageBody.text = messageText; + cell.link = nil; + cell.parent = self; + return cell; + } + if(cell == nil && [row.messageType isEqualToString:kMessageTypeFiletransfer]) + { + DDLogVerbose(@"got filetransfer chat cell: %@ (%@)", row.filetransferMimeType, row.filetransferSize); + NSDictionary* info = [MLFiletransfer getFileInfoForMessage:row]; + + if(![info[@"needsDownloading"] boolValue]) + { + DDLogVerbose(@"Filetransfer already downloaded: %@", info); + cell = [self fileTransferCellCheckerWithInfo:info direction:inboundDir tableView:tableView andMsg:row]; + } + else if([info[@"needsDownloading"] boolValue]) + { + DDLogVerbose(@"Filetransfer needs downloading: %@", info); + MLFileTransferDataCell* fileTransferCell = (MLFileTransferDataCell*)[self messageTableCellWithIdentifier:@"fileTransferCheckingData" andInbound:inboundDir fromTable:tableView]; + NSString* fileSize = info[@"size"] ? info[@"size"] : @"0"; + [fileTransferCell initCellForMessageId:row.messageDBId andFilename:info[@"filename"] andMimeType:info[@"mimeType"] andFileSize:fileSize.longLongValue]; + cell = fileTransferCell; + } + } + if(cell == nil && [row.messageType isEqualToString:kMessageTypeUrl] && [[HelperTools defaultsDB] boolForKey:@"ShowURLPreview"]) + { + DDLogVerbose(@"got link preview cell: %@", messageText); + MLLinkCell* toreturn = (MLLinkCell*)[self messageTableCellWithIdentifier:@"link" andInbound:inboundDir fromTable: tableView]; + + NSString* cleanLink = [messageText stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + NSArray* parts = [cleanLink componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + toreturn.link = parts[0]; + row.url = [NSURL URLWithString:toreturn.link]; + toreturn.messageBody.text = toreturn.link; + toreturn.messageHistoryId = row.messageDBId; + + if(row.previewText != nil || row.previewImage != nil) + { + if((row.previewText == nil || row.previewText.length == 0) && (row.previewImage == nil || row.previewImage.absoluteString.length == 0)) + { + DDLogWarn(@"Not showing preview for %@, preview unavailable: row.previewText=%@, row.previewImage=%@", messageText, row.previewText, row.previewImage); + toreturn = nil; //no preview available: use default MLChatCell for this + } + else + { + DDLogVerbose(@"Using db cached preview for %@", toreturn.link); + toreturn.imageUrl = row.previewImage; + toreturn.messageTitle.text = row.previewText; + [toreturn loadImageWithCompletion:^{}]; + } + } + else + { + DDLogVerbose(@"Loading link preview for %@", toreturn.link); + [self loadPreviewWithUrlForRow:indexPath withResultHandler:^{ + DDLogVerbose(@"Reloading row for preview: %@", messageText); + [[DataLayer sharedInstance] setMessageId:row.messageId previewText:[row.previewText copy] andPreviewImage:[row.previewImage.absoluteString copy]]; + //reload cells + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + }); + }]; + } + cell = toreturn; + } + if(cell == nil && [row.messageType isEqualToString:kMessageTypeGeo]) + { + DDLogVerbose(@"got geo cell: %@", messageText); + // Parse latitude and longitude + NSError* error = NULL; + NSRegularExpression* geoRegex = [NSRegularExpression regularExpressionWithPattern:geoPattern + options:NSRegularExpressionCaseInsensitive + error:&error]; + + if(error != NULL) { + DDLogError(@"Error while loading geoPattern"); + } + + NSTextCheckingResult* geoMatch = [geoRegex firstMatchInString:messageText options:0 range:NSMakeRange(0, [messageText length])]; + + if(geoMatch.numberOfRanges > 0) { + NSRange latitudeRange = [geoMatch rangeAtIndex:1]; + NSRange longitudeRange = [geoMatch rangeAtIndex:2]; + NSString* latitude = [messageText substringWithRange:latitudeRange]; + NSString* longitude = [messageText substringWithRange:longitudeRange]; + + // Display inline map + if([[HelperTools defaultsDB] boolForKey: @"ShowGeoLocation"]) { + MLChatMapsCell* mapsCell = (MLChatMapsCell*)[self messageTableCellWithIdentifier:@"maps" andInbound:inboundDir fromTable: tableView]; + + // Set lat / long used for map view and pin + mapsCell.latitude = [latitude doubleValue]; + mapsCell.longitude = [longitude doubleValue]; + + [mapsCell loadCoordinatesWithCompletion:^{}]; + cell = mapsCell; + } else { + // Default to text cell + cell = [self messageTableCellWithIdentifier:@"text" andInbound:inboundDir fromTable: tableView]; + NSMutableAttributedString* geoString = [[NSMutableAttributedString alloc] initWithString:messageText]; + [geoString addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:[geoMatch rangeAtIndex:0]]; + + cell.messageBody.attributedText = geoString; + NSInteger zoomLayer = 15; + cell.link = [NSString stringWithFormat:@"https://www.openstreetmap.org/?mlat=%@&mlon=%@&zoom=%ldd", latitude, longitude, zoomLayer]; + } + } else { + DDLogWarn(@"msgs of type kMessageTypeGeo should contain a geo location"); + } + } + if(cell == nil) + { + DDLogVerbose(@"got normal text cell: %@", messageText); + // Use default text cell + cell = (MLChatCell*)[self messageTableCellWithIdentifier:@"text" andInbound:inboundDir fromTable: tableView]; + + //make sure everything is set to defaults + cell.bubbleImage.hidden=NO; + UIFont* originalFont = [UIFont systemFontOfSize:17.0f]; + [cell.messageBody setFont:originalFont]; + + // Check if message contains a url + NSString* lowerCase = [messageText lowercaseString]; + NSRange pos = [lowerCase rangeOfString:@"https://"]; + if(pos.location == NSNotFound) { + pos = [lowerCase rangeOfString:@"http://"]; + } + if(pos.location == NSNotFound) { + pos = [lowerCase rangeOfString:@"xmpp:"]; + } + + NSRange pos2; + if(pos.location != NSNotFound) + { + NSString* urlString = [messageText substringFromIndex:pos.location]; + pos2 = [urlString rangeOfString:@" "]; + if(pos2.location == NSNotFound) { + pos2 = [urlString rangeOfString:@">"]; + } + + if(pos2.location != NSNotFound) { + urlString = [urlString substringToIndex:pos2.location]; + } + NSArray* parts = [urlString componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + cell.link = parts[0]; + + if(cell.link) { + NSMutableAttributedString *formattedString = [[NSMutableAttributedString alloc] initWithString:messageText]; + [formattedString addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:NSMakeRange(pos.location, cell.link.length)]; + cell.messageBody.text = nil; + cell.messageBody.attributedText= formattedString; + } + } + else // Default case + { + if(row.retracted) + { + NSString* stringToAttribute = NSLocalizedString(@"This message got retracted", @""); + UIFont* italicFont = [UIFont italicSystemFontOfSize:cell.messageBody.font.pointSize]; + NSMutableAttributedString* attributedMsgString = [[NSMutableAttributedString alloc] initWithString:stringToAttribute]; + [attributedMsgString addAttribute:NSFontAttributeName value:italicFont range:NSMakeRange(0, stringToAttribute.length)]; + [cell.messageBody setAttributedText:attributedMsgString]; + } + else if([MLEmoji containsEmojiWithText:messageText]) + { + UIFont* originalFont = [UIFont systemFontOfSize:cell.messageBody.font.pointSize*3]; + [cell.messageBody setFont:originalFont]; + [cell.messageBody setAttributedText:nil]; + [cell.messageBody setText:messageText]; + cell.bubbleImage.hidden=YES; + } + else if([messageText hasPrefix:@"/me "]) + { + UIFont* italicFont = [UIFont italicSystemFontOfSize:cell.messageBody.font.pointSize]; + + NSMutableAttributedString* attributedMsgString = [[MLXEPSlashMeHandler sharedInstance] attributedStringSlashMeWithMessage:row andFont:italicFont]; + + [cell.messageBody setAttributedText:attributedMsgString]; + } + else + { + // Reset attributes + UIFont* originalFont = [UIFont systemFontOfSize:cell.messageBody.font.pointSize]; + [cell.messageBody setFont:originalFont]; + [cell.messageBody setAttributedText:nil]; + [cell.messageBody setText:messageText]; + } + cell.link = nil; + } + } + MLMessage* priorRow = nil; + if(indexPath.row > 0) + priorRow = [self.messageList objectAtIndex:indexPath.row-1]; + // Only display names for groups + BOOL hideName = YES; + if(self.contact.isMuc) + { + if([kMucTypeGroup isEqualToString:self.contact.mucType] && row.participantJid) + hideName = (priorRow != nil && [priorRow.participantJid isEqualToString:row.participantJid]); + else + hideName = (priorRow != nil && [priorRow.actualFrom isEqualToString:row.actualFrom]); + //((MLMessage*)row).contactDisplayName will automatically use row.actualFrom as fallback for group-type mucs + //if no roster name or XEP-0172 nickname could be found and always use row.actualFrom for channel-type mucs + cell.name.text = hideName == YES ? nil : row.contactDisplayName; + } + // remove hidden text for better constraints + if(hideName == YES) + cell.name.text = nil; + cell.name.hidden = hideName; + + if(row.hasBeenDisplayed) + cell.messageStatus.text = kDisplayed; + else if(row.hasBeenReceived) + cell.messageStatus.text = kReceived; + else if(row.hasBeenSent) + cell.messageStatus.text = kSent; + else + cell.messageStatus.text = kSending; + + cell.messageHistoryId = row.messageDBId; + BOOL newSender = NO; + if(indexPath.row > 0) + { + if(priorRow.inbound != row.inbound) + newSender = YES; + } + cell.date.text = [self formattedTimeStampWithSource:row.timestamp]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + + cell.dividerDate.text = [self formattedDateWithSource:row.timestamp andPriorDate:priorRow.timestamp]; + + // Do not hide the lockImage if the message was encrypted + cell.lockImage.hidden = !row.encrypted; + // Set correct layout in/Outbound + cell.outBound = !inboundDir; + // Hide messageStatus on inbound messages + cell.messageStatus.hidden = inboundDir; + + cell.parent = self; + + if(cell.outBound && ([row.errorType length] > 0 || [row.errorReason length] > 0) && !row.hasBeenReceived && row.hasBeenSent) + { + cell.messageStatus.text = NSLocalizedString(@"Error", @""); + cell.deliveryFailed = YES; + } + + [cell updateCellWithNewSender:newSender]; + + if(!cell.link) + [self resetHistoryAttributeForCell:cell]; + if(self.searchController.isActive && row.messageDBId) + { + if([self.searchController isDBIdExistent:row.messageDBId]) + { + NSMutableAttributedString *attributedMsgString = [self.searchController doSearchKeyword:self.searchController.searchBar.text + onText:messageText + andInbound:inboundDir]; + [cell.messageBody setAttributedText:attributedMsgString]; + } + } + + return cell; +} + +-(MLContact*) getMLContactForJid:(NSString*) jid andAccount:(NSNumber*) accountID +{ + NSString* cacheKey = [NSString stringWithFormat:@"%@|%@", jid, accountID]; + @synchronized(_localMLContactCache) { + if(_localMLContactCache[cacheKey]) + return _localMLContactCache[cacheKey]; + return _localMLContactCache[cacheKey] = [MLContact createContactFromJid:jid andAccountID:accountID]; + } +} + +#pragma mark - tableview delegate +-(void) tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [self stopEditing]; + [self.chatInput resignFirstResponder]; + if(indexPath.section == reloadBoxSection) { + [self loadOldMsgHistory]; + } else if(indexPath.section == messagesSection) { + MLBaseCell* cell = [tableView cellForRowAtIndexPath:indexPath]; + if(cell.link) + { + if([cell respondsToSelector:@selector(openlink:)]) { + DDLogVerbose(@"Trying to open link in chat cell"); + [(MLChatCell *)cell openlink:self]; + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + NSDictionary* selectedItem = [MLFiletransfer getFileInfoForMessage:[self.messageList objectAtIndex:indexPath.row]]; + NSMutableArray* allItems = [[DataLayer sharedInstance] allAttachmentsFromContact:self.contact.contactJid forAccount:self.contact.accountID]; + UIViewController* imageViewer = [[SwiftuiInterface new] makeImageViewerForCurrentItem:selectedItem allItems:allItems]; + imageViewer.modalPresentationStyle = UIModalPresentationOverFullScreen; + [self presentViewController:imageViewer animated:YES completion:^{}]; + }); + } + } + } +} + +-(void) closePhotos { + [self.navigationController dismissViewControllerAnimated:YES completion:nil]; +} + +#pragma mark tableview datasource + +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath +{ + if(indexPath.section == reloadBoxSection) { + return NO; + } else { + return YES; // for now + } +} + +- (BOOL)tableView:(UITableView *)tableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath *)indexPath +{ + if(indexPath.section == reloadBoxSection) { + return NO; + } else { + return YES; + } +} + +-(UISwipeActionsConfiguration*) tableView:(UITableView*) tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath*) indexPath +{ + //stop editing (if there is some) on new swipe + [self stopEditing]; + + //don't allow swipe actions for our reload box + if(indexPath.section == reloadBoxSection) + return [UISwipeActionsConfiguration configurationWithActions:@[]]; + + //do some sanity checks + MLMessage* message; + if((NSUInteger)indexPath.row < self.messageList.count) + message = [self.messageList objectAtIndex:indexPath.row]; + else + { + DDLogError(@"Attempt to access beyond bounds"); + return [UISwipeActionsConfiguration configurationWithActions:@[]]; + } + if(message.messageDBId == nil) + return [UISwipeActionsConfiguration configurationWithActions:@[]]; + + //configure swipe actions + + UIContextualAction* LMCEditAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Edit", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { + [self.chatInput setText:message.messageText]; //we want to begin editing using the old message + self.placeHolderText.hidden = YES; + weakify(self); + self.editingCallback = ^(NSString* newBody) { + strongify(self); + self.editingCallback = nil; + if(newBody != nil) + { + message.messageText = newBody; + + [self.xmppAccount sendMessage:newBody toContact:self.contact isEncrypted:(self.contact.isEncrypted || message.encrypted) isUpload:NO andMessageId:[[NSUUID UUID] UUIDString] withLMCId:message.messageId]; + [[DataLayer sharedInstance] updateMessageHistory:message.messageDBId withText:newBody]; + + [self->_messageTable beginUpdates]; + [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self->_messageTable endUpdates]; + + //update active chats if necessary + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; + } + else + { + self.placeHolderText.hidden = NO; + [self.chatInput setText:@""]; + } + }; + // We don't know yet if the editingCallback will complete successful. Pretend anyway + return completionHandler(YES); + }]; + LMCEditAction.backgroundColor = UIColor.systemYellowColor; + LMCEditAction.image = [[[UIImage systemImageNamed:@"pencil.circle.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; + + UIContextualAction* quoteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Quote", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { + NSMutableString* filteredString = [NSMutableString new]; + //first of all: filter out already quoted text + [message.messageText enumerateLinesUsingBlock:^(NSString* _Nonnull line, BOOL* _Nonnull stop) { + if(line.length > 0 && [[line substringToIndex:1] isEqualToString:@">"]) + return; + [filteredString appendFormat:@"%@\n", line]; + }]; + NSMutableString* quoteString = [NSMutableString new]; + //add datetime before quoting message if message is older than 15 minutes and 8 messages + NSDate* timestamp = [[DataLayer sharedInstance] returnTimestampForQuote:message.messageDBId]; + if(timestamp != nil) + { + [self.destinationDateFormat setDateStyle:NSDateFormatterMediumStyle]; + [self.destinationDateFormat setTimeStyle:NSDateFormatterShortStyle]; + [quoteString appendFormat:@"%@:\n", [self.destinationDateFormat stringFromDate:timestamp]]; + } + //then: make sure we quote only trimmed message contents + [[filteredString stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet] enumerateLinesUsingBlock:^(NSString* _Nonnull line, BOOL* _Nonnull stop) { + [quoteString appendFormat:@"> %@\n", line]; + }]; + //Append new empty line after quote + [quoteString appendString:@"\n"]; + //add already typed in text back in + if(self.chatInput.text.length > 0) { + [quoteString appendString:self.chatInput.text]; + } + self.chatInput.text = quoteString; + self.placeHolderText.hidden = YES; + return completionHandler(YES); + }]; + quoteAction.backgroundColor = UIColor.systemGreenColor; + quoteAction.image = [[[UIImage systemImageNamed:@"quote.bubble.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; + + UIContextualAction* retractAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:NSLocalizedString(@"Retract", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { + //only delete directly if we sent that message, try to moderate otherwise + if(!message.inbound) + { + [self.xmppAccount retractMessage:message]; + [[DataLayer sharedInstance] retractMessageHistory:message.messageDBId]; + [message updateWithMessage:[[[DataLayer sharedInstance] messagesForHistoryIDs:@[message.messageDBId]] firstObject]]; + + //update table entry + [self->_messageTable beginUpdates]; + [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self->_messageTable endUpdates]; + + //update active chats if necessary + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; + } + else + { + //hardcode reason for now (change this when rewriting chatui using swiftui) + [self.xmppAccount moderateMessage:message withReason:@"This message contains inappropriate content for this forum."]; + } + + return completionHandler(YES); + }]; + retractAction.backgroundColor = UIColor.systemRedColor; + retractAction.image = [[[UIImage systemImageNamed:@"arrow.uturn.backward.circle.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; + + UIContextualAction* localDeleteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:NSLocalizedString(@"Delete", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { + [[DataLayer sharedInstance] deleteMessageHistoryLocally:message.messageDBId]; + + [self->_messageTable beginUpdates]; + [self.messageList removeObjectAtIndex:indexPath.row]; + [self->_messageTable deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationRight]; + [self->_messageTable endUpdates]; + + //update active chats if necessary + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; + + return completionHandler(YES); + }]; + localDeleteAction.backgroundColor = UIColor.systemYellowColor; + localDeleteAction.image = [[[UIImage systemImageNamed:@"trash.circle.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; + + UIContextualAction* copyAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:NSLocalizedString(@"Copy", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { + UIPasteboard* pasteboard = [UIPasteboard generalPasteboard]; + MLBaseCell* selectedCell = [self.messageTable cellForRowAtIndexPath:indexPath]; + if([selectedCell isKindOfClass:[MLChatImageCell class]]) + pasteboard.image = [(MLChatImageCell*)selectedCell getDisplayedImage]; + else if([selectedCell isKindOfClass:[MLLinkCell class]]) + pasteboard.URL = [NSURL URLWithString:((MLLinkCell*)selectedCell).link]; + else + pasteboard.string = message.messageText; + return completionHandler(YES); + }]; + copyAction.backgroundColor = UIColor.systemGreenColor; + copyAction.image = [[[UIImage systemImageNamed:@"doc.on.doc.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; + + //only allow editing for the 3 newest message && only on outgoing messages + if((!message.inbound && [[DataLayer sharedInstance] checkLMCEligible:message.messageDBId encrypted:(message.encrypted || self.contact.isEncrypted) historyBaseID:nil]) && (!message.isMuc || (message.isMuc && message.stanzaId != nil)) && !message.retracted) + return [UISwipeActionsConfiguration configurationWithActions:@[ + quoteAction, + copyAction, + LMCEditAction, + retractAction, + ]]; + else if(!message.inbound && [[DataLayer sharedInstance] checkLMCEligible:message.messageDBId encrypted:(message.encrypted || self.contact.isEncrypted) historyBaseID:nil] && !message.retracted) + return [UISwipeActionsConfiguration configurationWithActions:@[ + quoteAction, + copyAction, + LMCEditAction, + localDeleteAction, + ]]; + //only allow retraction for outgoing messages or if we are the moderator of that muc + //but only allow retraction in mucs if we already got the reflected stanzaid (or if this is an 1:1 chat) + else if((!message.inbound || (self.contact.isMuc && [[[DataLayer sharedInstance] getOwnRoleInGroupOrChannel:self.contact] isEqualToString:kMucRoleModerator] && [[self.xmppAccount.mucProcessor getRoomFeaturesForMuc:self.contact.contactJid] containsObject:@"urn:xmpp:message-moderate:1"])) && (!message.isMuc || (message.isMuc && message.stanzaId != nil)) && !message.retracted) + return [UISwipeActionsConfiguration configurationWithActions:@[ + quoteAction, + copyAction, + retractAction, + ]]; + else + return [UISwipeActionsConfiguration configurationWithActions:@[ + quoteAction, + copyAction, + localDeleteAction, + ]]; +} + +-(MLBaseCell*) fileTransferCellCheckerWithInfo:(NSDictionary*)info direction:(BOOL)inDirection tableView:(UITableView*)tableView andMsg:(MLMessage*)row{ + MLBaseCell* cell = nil; + if(cell == nil && [info[@"mimeType"] hasPrefix:@"image/"]) + { + MLChatImageCell* imageCell = (MLChatImageCell*)[self messageTableCellWithIdentifier:@"image" andInbound:inDirection fromTable:tableView]; + [imageCell initCellWithMLMessage:row]; + cell = imageCell; + } + if(cell == nil && [info[@"mimeType"] hasPrefix:@"video/"]) + { + MLFileTransferVideoCell* videoCell = (MLFileTransferVideoCell*)[self messageTableCellWithIdentifier:@"fileTransferVideo" andInbound:inDirection fromTable:tableView]; + NSString* videoStr = info[@"cacheFile"]; + NSString* videoFileName = info[@"filename"]; + [videoCell avplayerConfigWithUrlStr:videoStr andMimeType:info[@"mimeType"] fileName:videoFileName andVC:self]; + + cell = videoCell; + } + if(cell == nil && [info[@"mimeType"] hasPrefix:@"audio/"]) + { + //we may wan to make a new kind later but for now this is perfectly functional + MLFileTransferVideoCell* audioCell = (MLFileTransferVideoCell*)[self messageTableCellWithIdentifier:@"fileTransferAudio" andInbound:inDirection fromTable:tableView]; + NSString *audioStr = info[@"cacheFile"]; + NSString *audioFileName = info[@"filename"]; + [audioCell avplayerConfigWithUrlStr:audioStr andMimeType:info[@"mimeType"] fileName:audioFileName andVC:self]; + + cell = audioCell; + } + if(cell == nil) + { + MLFileTransferTextCell* textCell = (MLFileTransferTextCell*)[self messageTableCellWithIdentifier:@"fileTransferText" andInbound:inDirection fromTable:tableView]; + + NSString *fileSizeStr = info[@"size"]; + long long fileSizeLongLongValue = fileSizeStr.longLongValue; + NSString *readableFileSize = [NSByteCountFormatter stringFromByteCount:fileSizeLongLongValue + countStyle:NSByteCountFormatterCountStyleFile]; + NSString *hintStr = [NSString stringWithFormat:@"%@ %@", NSLocalizedString(@"Open", @""), info[@"filename"]]; + NSString *fileCacheUrlStr = info[@"cacheFile"]; + textCell.fileCacheUrlStr = fileCacheUrlStr; + + NSUInteger countOfMimtTypeComponent = [info[@"mimeType"] componentsSeparatedByString:@";"].count; + NSString* fileMimeType = @""; + NSString* fileCharSet = @""; + NSString* fileEncodeName = @"utf-8"; + if (countOfMimtTypeComponent > 1) + { + fileMimeType = [info[@"mimeType"] componentsSeparatedByString:@";"].firstObject; + fileCharSet = [info[@"mimeType"] componentsSeparatedByString:@";"].lastObject; + } + else + { + fileMimeType = info[@"mimeType"]; + } + + if (fileCharSet != nil && fileCharSet.length > 0) + { + fileEncodeName = [fileCharSet componentsSeparatedByString:@"="].lastObject; + } + + textCell.fileMimeType = fileMimeType; + textCell.fileName = info[@"filename"]; + textCell.fileEncodeName = fileEncodeName; + [textCell.fileTransferHint setText:hintStr]; + [textCell.sizeLabel setText:readableFileSize]; + textCell.openFileDelegate = self; + cell = textCell; + } + + return cell; +} + +//dummy function needed to remove warnign +-(void) openlink: (id) sender { + +} + +-(void) scrollViewDidScroll:(UIScrollView *)scrollView +{ + // Only load old msgs if the view appeared + if(!self.viewDidAppear) + return; + + // get current scroll position (y-axis) + CGFloat curOffset = scrollView.contentOffset.y; + CGFloat bottomLength = scrollView.frame.size.height + curOffset; + _isAtBottom = scrollView.contentSize.height <= bottomLength; + + if(_isAtBottom) + [self.lastMsgButton setHidden:YES]; + else + [self.lastMsgButton setHidden:NO]; + + +} + +-(void) loadOldMsgHistory +{ + [self.messageTable.refreshControl beginRefreshing]; + [self loadOldMsgHistory:self.messageTable.refreshControl]; +} + +-(void) loadOldMsgHistory:(id) sender +{ + // Load older messages from db + NSMutableArray* oldMessages = nil; + NSNumber* beforeId = nil; + if(self.messageList.count > 0) + beforeId = ((MLMessage*)[self.messageList objectAtIndex:0]).messageDBId; + oldMessages = [[DataLayer sharedInstance] messagesForContact:self.contact.contactJid forAccount:self.contact.accountID beforeMsgHistoryID:beforeId]; + + if(!self.isLoadingMam && [oldMessages count] < kMonalBackscrollingMsgCount) + { + self.isLoadingMam = YES; //don't allow multiple parallel mam fetches + + //not all messages in history db have a stanzaId (messages sent by this monal instance won't have one for example) + //--> search for the oldest message having a stanzaId and use that one + NSString* oldestStanzaId; + for(MLMessage* msg in oldMessages) + if(msg.stanzaId) + { + DDLogVerbose(@"Found oldest stanzaId in messages returned from db: %@", msg.stanzaId); + oldestStanzaId = msg.stanzaId; + break; + } + if(!oldestStanzaId) + { + for(MLMessage* msg in self.messageList) + { + if(msg.stanzaId) + { + DDLogVerbose(@"Found oldest stanzaId in messages already displayed: %@", msg.stanzaId); + oldestStanzaId = msg.stanzaId; + break; + } + } + } + + //history database for this contact is completely empty, use global last stanza id for this mam archive + if(oldestStanzaId == nil) + { + if(self.contact.isMuc) + oldestStanzaId = [[DataLayer sharedInstance] lastStanzaIdForMuc:self.contact.contactJid andAccount:self.contact.accountID]; + else + oldestStanzaId = [[DataLayer sharedInstance] lastStanzaIdForAccount:self.contact.accountID]; + } + + //now load more (older) messages from mam + DDLogVerbose(@"Loading more messages from mam before stanzaId %@", oldestStanzaId); + weakify(self); + [self.xmppAccount setMAMQueryMostRecentForContact:self.contact before:oldestStanzaId withCompletion:^(NSArray* _Nullable messages, NSString* _Nullable error) { + dispatch_async(dispatch_get_main_queue(), ^{ + strongify(self); + if(!messages && !error) + { + //xmpp account got reconnected + DDLogError(@"Got backscrolling mam error: nil (possible reconnect while querying)"); + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not fetch messages", @"") message:NSLocalizedString(@"The connection to the server was interrupted and no old messages could be fetched for this chat. Please try again later.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; + } + else if(!messages) + { + NSString* errorText = error; + if(!error) + errorText = NSLocalizedString(@"Unknown error!", @""); + DDLogError(@"Got backscrolling mam error: %@", errorText); + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not fetch messages", @"") message:[NSString stringWithFormat:NSLocalizedString(@"Could not fetch (all) old messages for this chat from your server archive. Please try again later. %@", @""), errorText] preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; + } + else + { + DDLogVerbose(@"Got backscrolling mam response: %lu", (unsigned long)[messages count]); + if([messages count] == 0) + { + self.moreMessagesAvailable = NO; + + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Finished fetching messages", @"") message:NSLocalizedString(@"All messages fetched successfully, there are no more left on the server!", @"") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; + } + else + [self insertOldMessages:[[messages reverseObjectEnumerator] allObjects]]; + } + //allow next mam fetch + self.isLoadingMam = NO; + if(sender) + [(UIRefreshControl*)sender endRefreshing]; + }); + }]; + } + else if(!self.isLoadingMam && [oldMessages count] >= kMonalBackscrollingMsgCount) + { + if(sender) + [(UIRefreshControl*)sender endRefreshing]; + } + + //insert everything we got from the db so far + if(oldMessages && [oldMessages count] > 0) + { + //use reverse order to insert messages from newest to oldest (bottom to top in chatview) + [self insertOldMessages:[[oldMessages reverseObjectEnumerator] allObjects]]; + } + else + { + [self doSetNotLoadingHistory]; + } +} + +-(void) insertOldMessages:(NSArray*) oldMessages +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if(!self.messageList) + self.messageList = [NSMutableArray new]; + + CGSize sizeBeforeAddingMessages = [self->_messageTable contentSize]; + // Insert old messages into messageTable + NSMutableArray* indexArray = [NSMutableArray array]; + for(size_t msgIdx = 0; msgIdx < [oldMessages count]; msgIdx++) + { + MLMessage* msg = [oldMessages objectAtIndex:msgIdx]; + [self.messageList insertObject:msg atIndex:0]; + NSIndexPath* newIndexPath = [NSIndexPath indexPathForRow:msgIdx inSection:messagesSection]; + [indexArray addObject:newIndexPath]; + } + [self->_messageTable beginUpdates]; + [self->_messageTable insertRowsAtIndexPaths:indexArray withRowAnimation:UITableViewRowAnimationNone]; + // keep old position - scrolling may stop + CGSize sizeAfterAddingMessages = [self->_messageTable contentSize]; + CGPoint contentOffset = self->_messageTable.contentOffset; + CGPoint newOffset = CGPointMake(contentOffset.x, contentOffset.y + sizeAfterAddingMessages.height - sizeBeforeAddingMessages.height); + self->_messageTable.contentOffset = newOffset; + [self->_messageTable endUpdates]; + + [self doSetNotLoadingHistory]; + }); +} + +-(BOOL) canBecomeFirstResponder +{ + return YES; +} + +-(UIView *) inputAccessoryView +{ + return self.inputContainerView; +} + +// Add new line to chatInput with 'shift + enter' +-(void) shiftEnterKeyPressed:(UIKeyCommand*)keyCommand +{ + if([self.chatInput isFirstResponder]) { + // Get current cursor postion + NSRange pos = [self.chatInput selectedRange]; + // Insert \n + self.chatInput.text = [self.chatInput.text stringByReplacingCharactersInRange:pos withString:@"\n"]; + } +} + +// Send message with 'enter' if chatInput is first repsonder +-(void) enterKeyPressed:(UIKeyCommand*)keyCommand +{ + if([self.chatInput isFirstResponder]) { + [self resignTextView]; + } +} + +// Open contact details +-(void) commandIPressed:(UIKeyCommand*)keyCommand +{ + [self performSegueWithIdentifier:@"showDetails" sender:self]; +} + +// Open search ViewController +-(void) commandFPressed:(UIKeyCommand*)keyCommand +{ + //[self showSeachButtonAction]; +} + +// List of custom hardware key commands +- (NSArray *)keyCommands { + // shift + enter + UIKeyCommand* shiftEnterKey = [UIKeyCommand keyCommandWithInput:@"\r" modifierFlags:UIKeyModifierShift action:@selector(shiftEnterKeyPressed:)]; + // enter + UIKeyCommand* enterKey = [UIKeyCommand keyCommandWithInput:@"\r" modifierFlags:0 action:@selector(enterKeyPressed:)]; + UIKeyCommand* escapeKey = [UIKeyCommand + keyCommandWithInput:UIKeyInputEscape modifierFlags:0 action:@selector(closePhotos)]; + // prefer our key commands over the system defaults + shiftEnterKey.wantsPriorityOverSystemBehavior = true; + enterKey.wantsPriorityOverSystemBehavior = true; + return @[ + shiftEnterKey, + enterKey, + escapeKey, + // command + i + [UIKeyCommand keyCommandWithInput:@"i" modifierFlags:UIKeyModifierCommand action:@selector(commandIPressed:)], + // command + f + [UIKeyCommand keyCommandWithInput:@"f" modifierFlags:UIKeyModifierCommand action:@selector(commandFPressed:)] + ]; +} + +# pragma mark - Textview delegate functions + +-(void) textViewDidBeginEditing:(UITextView*) textView +{ + [self scrollToBottomIfNeeded]; +} + +- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text +{ + BOOL shouldInsert = YES; + + // Notify that we are typing + [self sendChatState:YES]; + + // Limit text length to kMonalChatMaxAllowedTextLen + if([text isEqualToString:@""]) { + shouldInsert &= YES; + } else { + shouldInsert &= (range.location + range.length < kMonalChatMaxAllowedTextLen); + } + shouldInsert &= ([textView.text length] + [text length] - range.length <= kMonalChatMaxAllowedTextLen); + + return shouldInsert; +} + +- (void)textViewDidChange:(UITextView *)textView +{ + if(textView.text.length > 0) + self.placeHolderText.hidden = YES; + else + self.placeHolderText.hidden = NO; + + [self setSendButtonIconWithTextLength:[textView.text length]]; +} + +-(void) setSendButtonIconWithTextLength:(NSUInteger)txtLength +{ +#if TARGET_OS_MACCATALYST + self.isAudioMessage = NO; + [self.audioRecordButton setHidden:YES]; + [self.sendButton setHidden:NO]; +#else + if ((txtLength == 0) && (self.uploadQueue.count == 0)) + { + self.isAudioMessage = YES; + [self.audioRecordButton setHidden:NO]; + [self.sendButton setHidden:YES]; + } + else + { + self.isAudioMessage = NO; + [self.audioRecordButton setHidden:YES]; + [self.sendButton setHidden:NO]; + } +#endif +} + +#pragma mark - link preview + +-(void) loadPreviewWithUrlForRow:(NSIndexPath *) indexPath withResultHandler:(monal_void_block_t) resultHandler +{ + MLMessage* row; + if((NSUInteger)indexPath.row < self.messageList.count) + row = [self.messageList objectAtIndex:indexPath.row]; + else + { + DDLogError(@"Attempt to access beyond bounds"); + return; + } + + //prevent duplicated calls from cell animations (don't call resultHandler in this case because the resultHandler would reload the row) + if([self.previewedIds containsObject:row.messageDBId]) + { + DDLogDebug(@"Not loading preview for already pending row: %@ in %@", row.messageDBId, self.previewedIds); + return; + } + [self.previewedIds addObject:row.messageDBId]; + + row.previewText = @""; + row.previewImage = [NSURL URLWithString:@""]; + if(row.url) + { + DDLogVerbose(@"Fetching HTTP HEAD for %@...", row.url); + NSMutableURLRequest* headRequest = [[NSMutableURLRequest alloc] initWithURL:row.url]; + if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) + headRequest.requiresDNSSECValidation = YES; + headRequest.HTTPMethod = @"HEAD"; + headRequest.cachePolicy = NSURLRequestReturnCacheDataElseLoad; + NSURLSession* session = [HelperTools createEphemeralURLSession]; + [[session dataTaskWithRequest:headRequest completionHandler:^(NSData* _Nullable data, NSURLResponse* _Nullable response, NSError* _Nullable error) { + if(error != nil) + { + DDLogWarn(@"Loading preview HEAD for %@ failed: %@", row.url, error); + resultHandler(); + return; + } + + NSDictionary* headers = ((NSHTTPURLResponse*)response).allHeaderFields; + NSString* mimeType = [[headers objectForKey:@"Content-Type"] lowercaseString]; + NSNumber* contentLength = [headers objectForKey:@"Content-Length"] ? [NSNumber numberWithInt:([[headers objectForKey:@"Content-Length"] intValue])] : @(-1); + + if(mimeType.length==0) + { + DDLogWarn(@"Loading preview HEAD for %@ failed: mimeType unkown", row.url); + resultHandler(); + return; + } + //preview images, too + if([mimeType hasPrefix:@"image/"]) + { + DDLogVerbose(@"Now loading image preview data for: %@", row.url); + row.previewText = [row.url lastPathComponent]; + row.previewImage = row.url; + resultHandler(); + return; + } + if(![mimeType hasPrefix:@"text/"]) + { + DDLogWarn(@"Loading HEAD preview for %@ failed: mimeType not supported: %@", row.url, mimeType); + resultHandler(); + return; + } + //limit to 512KB of html + if(contentLength.intValue > 524288) + { + DDLogWarn(@"Now loading preview HTML for %@ with byte range 0-512k...", row.url); + [self downloadPreviewWithRow:indexPath usingByterange:YES andResultHandler:resultHandler]; + return; + } + + DDLogVerbose(@"Now loading preview for: %@", row.url); + [self downloadPreviewWithRow:indexPath usingByterange:NO andResultHandler:resultHandler]; + }] resume]; + } + else if(resultHandler) + { + DDLogWarn(@"Not loading HEAD preview for '%@': no url given!", row.url); + resultHandler(); + } +} + +-(void) downloadPreviewWithRow:(NSIndexPath*) indexPath usingByterange:(BOOL) useByterange andResultHandler:(monal_void_block_t) resultHandler +{ + MLMessage* row; + if((NSUInteger)indexPath.row < self.messageList.count) + row = [self.messageList objectAtIndex:indexPath.row]; + else + { + DDLogError(@"Attempt to access beyond bounds"); + return; + } + + /** + + + facebookexternalhit/1.1 + */ + DDLogVerbose(@"Fetching HTTP GET for %@...", row.url); + NSMutableURLRequest* request = [[NSMutableURLRequest alloc] initWithURL:row.url]; + if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) + request.requiresDNSSECValidation = YES; + [request setValue:@"facebookexternalhit/1.1" forHTTPHeaderField:@"User-Agent"]; //required on some sites for og tags e.g. youtube + if(useByterange) + [request setValue:@"bytes=0-524288" forHTTPHeaderField:@"Range"]; + request.timeoutInterval = 10; + NSURLSession* session = [HelperTools createEphemeralURLSession]; + [[session dataTaskWithRequest:request completionHandler:^(NSData* _Nullable data, NSURLResponse* _Nullable response, NSError* _Nullable error) { + if(error != nil) + DDLogVerbose(@"preview fetching error: %@", error); + else + { + NSString* body = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + MLOgHtmlParser* ogParser = nil; + NSString* text = nil; + NSURL* image = nil; + if([body length] > 524288) + body = [body substringToIndex:524288]; + NSURL* baseURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@://%@%@", row.url.scheme, row.url.host, row.url.path]]; + ogParser = [[MLOgHtmlParser alloc] initWithHtml:body andBaseUrl:baseURL]; + if(ogParser != nil) + { + text = [ogParser getOgTitle]; + image = [ogParser getOgImage]; + } + else + DDLogError(@"Could not create OG parser!"); + if((text != nil && text.length > 0) || (image != nil && image.absoluteString.length > 0)) + { + DDLogVerbose(@"Preview of %@: title=%@, image=%@", row.url, text, image); + row.previewText = text; + row.previewImage = image; + } + else + { + DDLogWarn(@"Preview of %@ is empty!", row.url); + row.previewText = @""; + row.previewImage = [NSURL URLWithString:@""]; + } + } + [self.previewedIds removeObject:row.messageDBId]; + resultHandler(); + }] resume]; +} + +#pragma mark - Keyboard + +- (void)keyboardWillDisappear:(NSNotification*) aNotification +{ + [self setChatInputHeightConstraints:YES]; +} + +- (void)keyboardDidShow:(NSNotification*)aNotification +{ + //TODO grab animation info + NSDictionary* info = [aNotification userInfo]; + CGSize kbSize = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue].size; + if(kbSize.height > 100) { //my inputbar +any other + self.hardwareKeyboardPresent = NO; + } + UIEdgeInsets contentInsets = UIEdgeInsetsMake(0.0, 0.0, kbSize.height - 10, 0.0); + self.messageTable.contentInset = contentInsets; + self.messageTable.scrollIndicatorInsets = contentInsets; + + //this will be automatically called once the whole chat view is loaded (even if not showing a keyboard) + [self scrollToBottomIfNeeded]; +} + +- (void)keyboardDidHide:(NSNotification*)aNotification +{ + [self saveMessageDraft]; + [self sendChatState:NO]; + + UIEdgeInsets contentInsets = UIEdgeInsetsZero; + self.messageTable.contentInset = contentInsets; + self.messageTable.scrollIndicatorInsets = contentInsets; +} + +- (void)keyboardWillShow:(NSNotification*)aNotification +{ + + [self setChatInputHeightConstraints:NO]; + //TODO grab animation info +// UIEdgeInsets contentInsets = UIEdgeInsetsZero; +// self.messageTable.contentInset = contentInsets; +// self.messageTable.scrollIndicatorInsets = contentInsets; +} + +-(void) tempfreezeAutoloading +{ + // Allow autoloading of more messages after a few seconds + self.viewIsScrolling = YES; + createTimer(1.5, (^{ + self.viewIsScrolling = NO; + })); +} + +-(void) stopEditing +{ + if(self.editingCallback) + self.editingCallback(nil); //dismiss swipe action +} + +-(void) checkOmemoSupportWithAlert:(BOOL) showWarning +{ +#ifndef DISABLE_OMEMO + if(self.xmppAccount && [[DataLayer sharedInstance] isAccountEnabled:self.xmppAccount.accountID]) + { + BOOL omemoDeviceForContactFound = NO; + if(!self.contact.isMuc) + omemoDeviceForContactFound = [self.xmppAccount.omemo knownDevicesForAddressName:self.contact.contactJid].count > 0; + else + { + omemoDeviceForContactFound = NO; + for(NSDictionary* participant in [[DataLayer sharedInstance] getMembersAndParticipantsOfMuc:self.contact.contactJid forAccountID:self.xmppAccount.accountID]) + { + if(participant[@"participant_jid"]) + omemoDeviceForContactFound |= [self.xmppAccount.omemo knownDevicesForAddressName:participant[@"participant_jid"]].count > 0; + else if(participant[@"member_jid"]) + omemoDeviceForContactFound |= [self.xmppAccount.omemo knownDevicesForAddressName:participant[@"member_jid"]].count > 0; + if(omemoDeviceForContactFound) + break; + } + } + if(!omemoDeviceForContactFound && self.contact.isEncrypted) + { + if(!self.contact.isMuc && [[HelperTools splitJid:self.contact.contactJid][@"host"] isEqualToString:@"cheogram.com"]) + { + // cheogram.com does not support OMEMO encryption as it is a PSTN gateway + // --> disable it + self.contact.isEncrypted = NO; + [[DataLayer sharedInstance] disableEncryptForJid:self.contact.contactJid andAccountID:self.contact.accountID]; + } + else if(self.contact.isMuc && ![self.contact.mucType isEqualToString:kMucTypeGroup]) + { + // a channel type muc has OMEMO encryption enabled, but channels don't support encryption + // --> disable it + self.contact.isEncrypted = NO; + [[DataLayer sharedInstance] disableEncryptForJid:self.contact.contactJid andAccountID:self.contact.accountID]; + } + else if(!self.contact.isMuc || (self.contact.isMuc && [self.contact.mucType isEqualToString:kMucTypeGroup])) + { + [self hideOmemoHUD]; + if(showWarning) + { + DDLogWarn(@"Showing omemo not supported alert for: %@", self.contact); + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"No OMEMO keys found", @"") message:NSLocalizedString(@"This contact may not support OMEMO encrypted messages. Please try to enable encryption again in a few seconds, if you think this is wrong.", @"") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Disable Encryption", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + // Disable encryption + self.contact.isEncrypted = NO; + [self updateUIElements]; + [[DataLayer sharedInstance] disableEncryptForJid:self.contact.contactJid andAccountID:self.contact.accountID]; + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; + } + else + { + // async dispatch is needed to show hud on chat open + // we won't do this twice, because the user won't be able to change isEncrypted to YES, + // unless we have omemo devices for that contact + dispatch_async(dispatch_get_main_queue(), ^{ + [self showOmemoHUD]; + }); + // request omemo devicelist + [self.xmppAccount.omemo subscribeAndFetchDevicelistIfNoSessionExistsForJid:self.contact.contactJid]; + } + } + } + else + [self hideOmemoHUD]; + } +#endif +} + +-(void) showOmemoHUD +{ + DDLogVerbose(@"Showing omemo HUD..."); + if(!self.omemoHUD) + { + self.omemoHUD = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + self.omemoHUD.removeFromSuperViewOnHide = YES; + self.omemoHUD.label.text = NSLocalizedString(@"Loading OMEMO keys", @""); + } + else + self.omemoHUD.hidden = NO; +} + +-(void) hideOmemoHUD +{ + DDLogVerbose(@"Hiding omemo HUD..."); + self.omemoHUD.hidden = YES; +} + +-(void) handleOmemoFetchStateUpdate:(NSNotification*) notification +{ + xmpp* account = notification.object; + MLContact* contact = [MLContact createContactFromJid:notification.userInfo[@"jid"] andAccountID:account.accountID]; + if(self.contact && [self.contact isEqualToContact:contact]) + { + DDLogDebug(@"Got omemo fetching update: %@ --> %@", contact, notification.userInfo); + if(!((NSNumber*)notification.userInfo[@"isFetching"]).boolValue) + dispatch_async(dispatch_get_main_queue(), ^{ + //recheck support and show alert if needed + DDLogVerbose(@"Rechecking omemo support with alert, if needed..."); + [self checkOmemoSupportWithAlert:YES]; + }); + } +} + +-(void) showUploadHUD +{ + if(!self.uploadHUD) + { + self.uploadHUD = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; + self.uploadHUD.removeFromSuperViewOnHide = YES; + self.uploadHUD.label.text = NSLocalizedString(@"Uploading", @""); + self.uploadHUD.detailsLabel.text = NSLocalizedString(@"Uploading file to server", @""); + } + else + self.uploadHUD.hidden = NO; +} + +-(void) hideUploadHUD +{ + self.uploadHUD.hidden = YES; +} + +-(void) showPotentialError:(NSError*) error +{ + if(error) + { + DDLogError(@"Could not send attachment: %@", error); + UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not upload file", @"") message:[NSString stringWithFormat:@"%@", error.localizedDescription] preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [alert dismissViewControllerAnimated:YES completion:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; + } +} + +#pragma mark - MLFileTransferTextCell delegate +-(void) showData:(NSString *)fileUrlStr withMimeType:(NSString *)mimeType andFileName:(NSString * _Nonnull)fileName andFileEncodeName:(NSString * _Nonnull)encodeName +{ + MLFileTransferFileViewController *fileViewController = [MLFileTransferFileViewController new]; + fileViewController.fileUrlStr = fileUrlStr; + fileViewController.mimeType = mimeType; + fileViewController.fileName = fileName; + fileViewController.fileEncodeName = encodeName; + [self presentViewController:fileViewController animated:NO completion:nil]; +// [self.navigationController pushViewController:fileViewController animated:NO]; +} + +#pragma mark - MLAudioRecoderManager delegate +-(void) notifyStart +{ + dispatch_async(dispatch_get_main_queue(), ^{ + CGFloat infoHeight = self.inputContainerView.frame.size.height; + CGFloat infoWidth = self.inputContainerView.frame.size.width; + + UIColor* labelBackgroundColor = self.inputContainerView.backgroundColor; + self.audioRecoderInfoView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, infoWidth - 50, infoHeight)]; + self.audioRecoderInfoView.backgroundColor = labelBackgroundColor; + UILabel *audioTimeInfoLabel = [[UILabel alloc] initWithFrame:CGRectMake(5, 0, infoWidth - 50, infoHeight)]; + [audioTimeInfoLabel setText:NSLocalizedString(@"Recording audio", @"")]; + [self.audioRecoderInfoView addSubview:audioTimeInfoLabel]; + [self.inputContainerView addSubview:self.audioRecoderInfoView]; + + [self.audioButton setTitleColor:[UIColor redColor] forState:UIControlStateNormal]; + [self.audioButton setTitleColor:[UIColor redColor] forState:UIControlStateHighlighted]; + [self.audioButton setTitleColor:[UIColor redColor] forState:UIControlStateSelected]; + }); +} + +-(void) notifyStop:(NSURL* _Nullable) fileURL +{ + dispatch_async(dispatch_get_main_queue(), ^{ + self->_isRecording = NO; + [self.audioRecoderInfoView removeFromSuperview]; + [self.audioButton setTitleColor:[UIColor blueColor] forState:UIControlStateNormal]; + [self.audioButton setTitleColor:[UIColor blueColor] forState:UIControlStateHighlighted]; + [self.audioButton setTitleColor:[UIColor blueColor] forState:UIControlStateSelected]; + + if(fileURL != nil) + [self showUploadHUD]; + }); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + + NSFileCoordinator* coordinator = [NSFileCoordinator new]; + + [coordinator coordinateReadingItemAtURL:fileURL options:NSFileCoordinatorReadingForUploading error:nil byAccessor:^(NSURL * _Nonnull newURL) { + [MLFiletransfer uploadFile:newURL onAccount:self.xmppAccount withEncryption:self.contact.isEncrypted andCompletion:^(NSString* url, NSString* mimeType, NSNumber* size, NSError* error) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self showPotentialError:error]; + if(!error) + { + NSString* newMessageID = [[NSUUID UUID] UUIDString]; + MLMessage* msg = [self addMessageto:self.contact.contactJid withMessage:url andId:newMessageID messageType:kMessageTypeFiletransfer mimeType:mimeType size:size]; + [[MLXMPPManager sharedInstance] sendMessage:url toContact:self.contact isEncrypted:self.contact.isEncrypted isUpload:YES messageId:newMessageID withCompletionHandler:^(BOOL success, NSString *messageId) { + DDLogInfo(@"File upload sent to contact..."); + [MLFiletransfer hardlinkFileForMessage:msg]; //hardlink cache file if possible + [self hideUploadHUD]; + }]; + } + DDLogVerbose(@"upload done"); + }); + }]; + }]; + }); +} + +-(void) updateCurrentTime:(NSTimeInterval) audioDuration +{ + int durationMinutes = (int)audioDuration/60; + int durationSeconds = (int)audioDuration - durationMinutes*60; + + for (UIView* subview in self.audioRecoderInfoView.subviews) { + if([subview isKindOfClass:[UILabel class]]){ + UILabel *infoLabel = (UILabel*)subview; + [infoLabel setText:[NSString stringWithFormat:NSLocalizedString(@"%02d:%02d (long press to abort)", @""), durationMinutes, durationSeconds]]; + [infoLabel setTextColor:[UIColor blackColor]]; + } + } +} + +-(void) notifyResult:(BOOL)isSuccess error:(NSString*) errorMsg +{ + dispatch_async(dispatch_get_main_queue(), ^{ + self->_isRecording = NO; + NSString* alertTitle = @""; + if(isSuccess) { + alertTitle = NSLocalizedString(@"Recode Success", @""); + } else { + alertTitle = [NSString stringWithFormat:@"%@%@", NSLocalizedString(@"Recode Fail:", @""), errorMsg]; + } + + UIAlertController* audioRecoderAlert = [UIAlertController alertControllerWithTitle:alertTitle + message:@"" preferredStyle:UIAlertControllerStyleAlert]; + + [self presentViewController:audioRecoderAlert animated:YES completion:^{ + dispatch_queue_t queue = dispatch_get_main_queue(); + dispatch_after(2.0, queue, ^{ + [audioRecoderAlert dismissViewControllerAnimated:YES completion:nil]; + }); + }]; + }); +} + +# pragma mark - Upload Queue (Backend) + +-(void) handleMediaUploadCompletion:(NSString*) url withMime:(NSString*) mimeType withSize:(NSNumber*) size withError:(NSError*) error +{ + monal_void_block_t handleNextUpload = ^{ + if(self.uploadQueue.count > 0) + { + [self.uploadMenuView performBatchUpdates:^{ + [self deleteQueueItemAtIndex:0]; + } completion:^(BOOL finished){ + [self emptyUploadQueue]; + }]; + } + else + { + [self hideUploadQueue]; + [self hideUploadHUD]; + } + }; + DDLogVerbose(@"Now in upload completion"); + [self showPotentialError:error]; + if(!error) + { + NSString* newMessageID = [[NSUUID UUID] UUIDString]; + MLMessage* msg = [self addMessageto:self.contact.contactJid withMessage:url andId:newMessageID messageType:kMessageTypeFiletransfer mimeType:mimeType size:size]; + [[MLXMPPManager sharedInstance] sendMessage:url toContact:self.contact isEncrypted:self.contact.isEncrypted isUpload:YES messageId:newMessageID withCompletionHandler:^(BOOL success, NSString *messageId) { + DDLogInfo(@"File upload sent to contact..."); + [MLFiletransfer hardlinkFileForMessage:msg]; //hardlink cache file if possible + handleNextUpload(); + }]; + DDLogInfo(@"upload done"); + } + else + handleNextUpload(); +} + +-(void) emptyUploadQueue +{ + if(self.uploadQueue.count == 0) + { + [self hideUploadQueue]; + [self hideUploadHUD]; + return; + } + MLAssert(self.uploadQueue.count >= 1, @"upload queue contains less than 1 element"); + [self showUploadHUD]; + + NSDictionary* payload = self.uploadQueue.firstObject; + MLAssert([payload[@"type"] isEqualToString:@"image"] || [payload[@"type"] isEqualToString:@"file"] || [payload[@"type"] isEqualToString:@"contact"] || [payload[@"type"] isEqualToString:@"audiovisual"], @"Payload type must be of type image, file contact or audiovisual!", payload); + + DDLogVerbose(@"start dispatch"); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + $call(payload[@"data"], $ID(account, self.xmppAccount), $BOOL(encrypted, self.contact.isEncrypted), $ID(completion, (^(NSString* url, NSString* mimeType, NSNumber* size, NSError* error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if(error != nil) + [self handleMediaUploadCompletion:nil withMime:nil withSize:nil withError:error]; + else + [self handleMediaUploadCompletion:url withMime:mimeType withSize:size withError:error]; + }); + }))); + }); +} + +# pragma mark - Upload Queue (UI) +-(void) showUploadQueue +{ + self.uploadMenuConstraint.constant = 180; + self.uploadMenuView.hidden = NO; +} + +-(void) hideUploadQueue +{ + [self setSendButtonIconWithTextLength:[self.chatInput.text length]]; + self.uploadMenuConstraint.constant = 1; // Can't set this to 0, because this will disable the view. If this were to happen, we would not use an accurate queue count if a user empties the queue and fills it afterwards. This is a hack to prevent this behaviour + self.uploadMenuView.hidden = YES; +} + +-(void) deleteQueueItemAtIndex:(NSUInteger) index +{ + if(self.uploadQueue.count == 1) // Delete last object in queue + { + [self.uploadMenuView deleteItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:index + 1 inSection:0]]]; // Delete '+' icon if queue is empty + } + [self.uploadQueue removeObjectAtIndex:index]; + [self.uploadMenuView deleteItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:index inSection:0]]]; +} + +-(void) addToUIQueue:(NSArray*) newItems +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if(self.uploadQueue.count == 0 && newItems.count > 0) // Queue was previously empty but will be filled now + { + // Force reload of view because this fails after the queue was emptied once otherwise. + // The '+' cell may also not be in the collection view yet when this function is called. + [CATransaction begin]; + [UIView setAnimationsEnabled:NO]; + [self showUploadQueue]; + [self.uploadMenuView performBatchUpdates:^{ + [self.uploadQueue addObjectsFromArray:newItems]; + NSMutableArray* newInd = [[NSMutableArray alloc] initWithCapacity:newItems.count + 1]; + for(NSUInteger i = 0; i <= newItems.count; i++) + { + newInd[i] = [NSIndexPath indexPathForItem:i inSection:0]; + } + DDLogVerbose(@"Inserting items at index paths: %@", newInd); + [self.uploadMenuView insertItemsAtIndexPaths:newInd]; + } completion:^(BOOL finished) { + [CATransaction commit]; + [UIView setAnimationsEnabled:YES]; + [self setSendButtonIconWithTextLength:[self.chatInput.text length]]; + }]; + } + else + { + [self.uploadMenuView performBatchUpdates:^{ + // Add all new elements + NSUInteger start = self.uploadQueue.count; + [self.uploadMenuView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:start inSection:0]]]; + [self.uploadQueue addObjectsFromArray:newItems]; + NSUInteger newElementsInSet = self.uploadQueue.count - start; + NSMutableArray* newInd = [[NSMutableArray alloc] initWithCapacity:newElementsInSet]; + for(NSUInteger i = 0; i < newElementsInSet; i++) + { + newInd[i] = [NSIndexPath indexPathForItem:start + i + 1 inSection:0]; + } + DDLogVerbose(@"Inserting items at index paths: %@", newInd); + [self.uploadMenuView insertItemsAtIndexPaths:newInd]; + } completion:^(BOOL finished) { + [self.uploadMenuView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:self.uploadQueue.count inSection:0] atScrollPosition:UICollectionViewScrollPositionRight animated:YES]; + [self setSendButtonIconWithTextLength:[self.chatInput.text length]]; + }]; + } + }); +} + +-(nonnull __kindof UICollectionViewCell*) collectionView:(nonnull UICollectionView*) collectionView cellForItemAtIndexPath:(nonnull NSIndexPath*) indexPath +{ + // the '+' tile + if((NSUInteger)indexPath.item == self.uploadQueue.count) + return [self.uploadMenuView dequeueReusableCellWithReuseIdentifier:@"addToUploadQueueCell" forIndexPath:indexPath]; + else + { + MLAssert(self.uploadQueue.count >= (NSUInteger)indexPath.item, @"index path is greater than count in upload queue"); + NSDictionary* uploadItem = self.uploadQueue[indexPath.item]; + // https://developer.apple.com/documentation/uikit/uicollectionview/1618063-dequeuereusablecellwithreuseiden?language=objc? + MLUploadQueueCell* cell = (MLUploadQueueCell*) [self.uploadMenuView dequeueReusableCellWithReuseIdentifier:@"UploadQueueCell" forIndexPath:indexPath]; + [cell initCellWithPreviewImage:uploadItem[@"preview"] filename:uploadItem[@"filename"] index:indexPath.item]; + [cell setUploadQueueDelegate:self]; + return cell; + } +} + +-(NSInteger) numberOfSectionsInCollectionView:(UICollectionView*) collectionView +{ + return 1; +} + +-(NSInteger)collectionView:(nonnull UICollectionView*) collectionView numberOfItemsInSection:(NSInteger) section +{ + MLAssert(section == 0, @"section is only allowed to be zero"); + return self.uploadQueue.count == 0 ? 0 : self.uploadQueue.count + 1; +} + +-(void) notifyUploadQueueRemoval:(NSUInteger) index +{ + if(index >= self.uploadQueue.count) + return; + [self.uploadMenuView performBatchUpdates:^{ + [self deleteQueueItemAtIndex:index]; + } completion:^(BOOL finished) { + // Fix all indices accordingly + for(NSUInteger i = 0; i < self.uploadQueue.count; i++) + { + MLUploadQueueCell* tmp = (MLUploadQueueCell*)[self.uploadMenuView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:i inSection: 0]]; + tmp.index = i; + } + + // Don't show uploadMenuView if queue is empty again + if(self.uploadQueue.count == 0) + { + [self hideUploadQueue]; + } + }]; +} + +-(IBAction) addImageToUploadQueue +{ + [self presentViewController:[self generatePHPickerViewController] animated:YES completion:nil]; +} + +-(void) dropInteraction:(UIDropInteraction*) interaction performDrop:(id) session +{ + for(UIDragItem* item in session.items) + { + NSItemProvider* provider = item.itemProvider; + MLAssert(provider != nil, @"provider must not be nil"); + MLAssert([provider hasItemConformingToTypeIdentifier:UTTypeItem.identifier], @"provider must supply item conforming to kUTTypeItem"); + [HelperTools handleUploadItemProvider:provider withCompletionHandler:^(NSMutableDictionary* _Nullable payload) { + dispatch_async(dispatch_get_main_queue(), ^{ + if(payload == nil || payload[@"error"] != nil) + { + DDLogError(@"Could not save payload for sending: %@", payload[@"error"]); + NSString* message = NSLocalizedString(@"Monal was not able to send your attachment!", @""); + if(payload[@"error"] != nil) + message = [NSString stringWithFormat:NSLocalizedString(@"Monal was not able to send your attachment: %@", @""), [payload[@"error"] localizedDescription]]; + UIAlertController* unknownItemWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not send", @"") + message:message preferredStyle:UIAlertControllerStyleAlert]; + [unknownItemWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Abort", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { + [unknownItemWarning dismissViewControllerAnimated:YES completion:nil]; + [self.extensionContext completeRequestReturningItems:@[] completionHandler:nil]; + }]]; + [self presentViewController:unknownItemWarning animated:YES completion:nil]; + } + else + [self addToUIQueue:@[payload]]; + }); + }]; + } +} + +-(UIDropProposal*) dropInteraction:(UIDropInteraction*) interaction sessionDidUpdate:(id) session +{ + return [[UIDropProposal alloc] initWithDropOperation:UIDropOperationCopy]; +} + +@end diff --git a/Monal/Classes/commithash.h b/Monal/Classes/commithash.h new file mode 100644 index 0000000..967e5cd --- /dev/null +++ b/Monal/Classes/commithash.h @@ -0,0 +1 @@ +#define ALPHA_COMMIT_HASH "HEAD" \ No newline at end of file diff --git a/Monal/Classes/hsluv.c b/Monal/Classes/hsluv.c new file mode 100644 index 0000000..f17dd36 --- /dev/null +++ b/Monal/Classes/hsluv.c @@ -0,0 +1,453 @@ +/* + * HSLuv-C: Human-friendly HSL + * + * + * + * Copyright (c) 2015 Alexei Boronine (original idea, JavaScript implementation) + * Copyright (c) 2015 Roger Tallada (Obj-C implementation) + * Copyright (c) 2017 Martin Mitas (C implementation, based on Obj-C implementation) + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +#include "hsluv.h" + +#include +#include + + +typedef struct Triplet_tag Triplet; +struct Triplet_tag { + double a; + double b; + double c; +}; + +/* for RGB */ +static const Triplet m[3] = { + { 3.24096994190452134377, -1.53738317757009345794, -0.49861076029300328366 }, + { -0.96924363628087982613, 1.87596750150772066772, 0.04155505740717561247 }, + { 0.05563007969699360846, -0.20397695888897656435, 1.05697151424287856072 } +}; + +/* for XYZ */ +static const Triplet m_inv[3] = { + { 0.41239079926595948129, 0.35758433938387796373, 0.18048078840183428751 }, + { 0.21263900587151035754, 0.71516867876775592746, 0.07219231536073371500 }, + { 0.01933081871559185069, 0.11919477979462598791, 0.95053215224966058086 } +}; + +static const double ref_u = 0.19783000664283680764; +static const double ref_v = 0.46831999493879100370; + +static const double kappa = 903.29629629629629629630; +static const double epsilon = 0.00885645167903563082; + + +typedef struct Bounds_tag Bounds; +struct Bounds_tag { + double a; + double b; +}; + + +static void +get_bounds(double l, Bounds bounds[6]) +{ + double tl = l + 16.0; + double sub1 = (tl * tl * tl) / 1560896.0; + double sub2 = (sub1 > epsilon ? sub1 : (l / kappa)); + int channel; + int t; + + for(channel = 0; channel < 3; channel++) { + double m1 = m[channel].a; + double m2 = m[channel].b; + double m3 = m[channel].c; + + for (t = 0; t < 2; t++) { + double top1 = (284517.0 * m1 - 94839.0 * m3) * sub2; + double top2 = (838422.0 * m3 + 769860.0 * m2 + 731718.0 * m1) * l * sub2 - 769860.0 * t * l; + double bottom = (632260.0 * m3 - 126452.0 * m2) * sub2 + 126452.0 * t; + + bounds[channel * 2 + t].a = top1 / bottom; + bounds[channel * 2 + t].b = top2 / bottom; + } + } +} + +static double +intersect_line_line(const Bounds* line1, const Bounds* line2) +{ + return (line1->b - line2->b) / (line2->a - line1->a); +} + +static double +dist_from_pole_squared(double x, double y) +{ + return x * x + y * y; +} + +static double +ray_length_until_intersect(double theta, const Bounds* line) +{ + return line->b / (sin(theta) - line->a * cos(theta)); +} + +static double +max_safe_chroma_for_l(double l) +{ + double min_len_squared = DBL_MAX; + Bounds bounds[6]; + int i; + + get_bounds(l, bounds); + for(i = 0; i < 6; i++) { + double m1 = bounds[i].a; + double b1 = bounds[i].b; + /* x where line intersects with perpendicular running though (0, 0) */ + Bounds line2 = { -1.0 / m1, 0.0 }; + double x = intersect_line_line(&bounds[i], &line2); + double distance = dist_from_pole_squared(x, b1 + x * m1); + + if(distance < min_len_squared) + min_len_squared = distance; + } + + return sqrt(min_len_squared); +} + +static double +max_chroma_for_lh(double l, double h) +{ + double min_len = DBL_MAX; + double hrad = h * 0.01745329251994329577; /* (2 * pi / 360) */ + Bounds bounds[6]; + int i; + + get_bounds(l, bounds); + for(i = 0; i < 6; i++) { + double len = ray_length_until_intersect(hrad, &bounds[i]); + + if(len >= 0 && len < min_len) + min_len = len; + } + return min_len; +} + +static double +dot_product(const Triplet* t1, const Triplet* t2) +{ + return (t1->a * t2->a + t1->b * t2->b + t1->c * t2->c); +} + +/* Used for rgb conversions */ +static double +from_linear(double c) +{ + if(c <= 0.0031308) + return 12.92 * c; + else + return 1.055 * pow(c, 1.0 / 2.4) - 0.055; +} + +static double +to_linear(double c) +{ + if (c > 0.04045) + return pow((c + 0.055) / 1.055, 2.4); + else + return c / 12.92; +} + +static void +xyz2rgb(Triplet* in_out) +{ + double r = from_linear(dot_product(&m[0], in_out)); + double g = from_linear(dot_product(&m[1], in_out)); + double b = from_linear(dot_product(&m[2], in_out)); + in_out->a = r; + in_out->b = g; + in_out->c = b; +} + +static void +rgb2xyz(Triplet* in_out) +{ + Triplet rgbl = { to_linear(in_out->a), to_linear(in_out->b), to_linear(in_out->c) }; + double x = dot_product(&m_inv[0], &rgbl); + double y = dot_product(&m_inv[1], &rgbl); + double z = dot_product(&m_inv[2], &rgbl); + in_out->a = x; + in_out->b = y; + in_out->c = z; +} + +/* https://en.wikipedia.org/wiki/CIELUV + * In these formulas, Yn refers to the reference white point. We are using + * illuminant D65, so Yn (see refY in Maxima file) equals 1. The formula is + * simplified accordingly. + */ +static double +y2l(double y) +{ + if(y <= epsilon) + return y * kappa; + else + return 116.0 * cbrt(y) - 16.0; +} + +static double +l2y(double l) +{ + if(l <= 8.0) { + return l / kappa; + } else { + double x = (l + 16.0) / 116.0; + return (x * x * x); + } +} + +static void +xyz2luv(Triplet* in_out) +{ + double var_u = (4.0 * in_out->a) / (in_out->a + (15.0 * in_out->b) + (3.0 * in_out->c)); + double var_v = (9.0 * in_out->b) / (in_out->a + (15.0 * in_out->b) + (3.0 * in_out->c)); + double l = y2l(in_out->b); + double u = 13.0 * l * (var_u - ref_u); + double v = 13.0 * l * (var_v - ref_v); + + in_out->a = l; + if(l < 0.00000001) { + in_out->b = 0.0; + in_out->c = 0.0; + } else { + in_out->b = u; + in_out->c = v; + } +} + +static void +luv2xyz(Triplet* in_out) +{ + if(in_out->a <= 0.00000001) { + /* Black will create a divide-by-zero error. */ + in_out->a = 0.0; + in_out->b = 0.0; + in_out->c = 0.0; + return; + } + + double var_u = in_out->b / (13.0 * in_out->a) + ref_u; + double var_v = in_out->c / (13.0 * in_out->a) + ref_v; + double y = l2y(in_out->a); + double x = -(9.0 * y * var_u) / ((var_u - 4.0) * var_v - var_u * var_v); + double z = (9.0 * y - (15.0 * var_v * y) - (var_v * x)) / (3.0 * var_v); + in_out->a = x; + in_out->b = y; + in_out->c = z; +} + +static void +luv2lch(Triplet* in_out) +{ + double l = in_out->a; + double u = in_out->b; + double v = in_out->c; + double h; + double c = sqrt(u * u + v * v); + + /* Grays: disambiguate hue */ + if(c < 0.00000001) { + h = 0; + } else { + h = atan2(v, u) * 57.29577951308232087680; /* (180 / pi) */ + if(h < 0.0) + h += 360.0; + } + + in_out->a = l; + in_out->b = c; + in_out->c = h; +} + +static void +lch2luv(Triplet* in_out) +{ + double hrad = in_out->c * 0.01745329251994329577; /* (pi / 180.0) */ + double u = cos(hrad) * in_out->b; + double v = sin(hrad) * in_out->b; + + in_out->b = u; + in_out->c = v; +} + +static void +hsluv2lch(Triplet* in_out) +{ + double h = in_out->a; + double s = in_out->b; + double l = in_out->c; + double c; + + /* White and black: disambiguate chroma */ + if(l > 99.9999999 || l < 0.00000001) + c = 0.0; + else + c = max_chroma_for_lh(l, h) / 100.0 * s; + + /* Grays: disambiguate hue */ + if (s < 0.00000001) + h = 0.0; + + in_out->a = l; + in_out->b = c; + in_out->c = h; +} + +static void +lch2hsluv(Triplet* in_out) +{ + double l = in_out->a; + double c = in_out->b; + double h = in_out->c; + double s; + + /* White and black: disambiguate saturation */ + if(l > 99.9999999 || l < 0.00000001) + s = 0.0; + else + s = c / max_chroma_for_lh(l, h) * 100.0; + + /* Grays: disambiguate hue */ + if (c < 0.00000001) + h = 0.0; + + in_out->a = h; + in_out->b = s; + in_out->c = l; +} + +static void +hpluv2lch(Triplet* in_out) +{ + double h = in_out->a; + double s = in_out->b; + double l = in_out->c; + double c; + + /* White and black: disambiguate chroma */ + if(l > 99.9999999 || l < 0.00000001) + c = 0.0; + else + c = max_safe_chroma_for_l(l) / 100.0 * s; + + /* Grays: disambiguate hue */ + if (s < 0.00000001) + h = 0.0; + + in_out->a = l; + in_out->b = c; + in_out->c = h; +} + +static void +lch2hpluv(Triplet* in_out) +{ + double l = in_out->a; + double c = in_out->b; + double h = in_out->c; + double s; + + /* White and black: disambiguate saturation */ + if (l > 99.9999999 || l < 0.00000001) + s = 0.0; + else + s = c / max_safe_chroma_for_l(l) * 100.0; + + /* Grays: disambiguate hue */ + if (c < 0.00000001) + h = 0.0; + + in_out->a = h; + in_out->b = s; + in_out->c = l; +} + + + +void +hsluv2rgb(double h, double s, double l, double* pr, double* pg, double* pb) +{ + Triplet tmp = { h, s, l }; + + hsluv2lch(&tmp); + lch2luv(&tmp); + luv2xyz(&tmp); + xyz2rgb(&tmp); + + *pr = tmp.a; + *pg = tmp.b; + *pb = tmp.c; +} + +void +hpluv2rgb(double h, double s, double l, double* pr, double* pg, double* pb) +{ + Triplet tmp = { h, s, l }; + + hpluv2lch(&tmp); + lch2luv(&tmp); + luv2xyz(&tmp); + xyz2rgb(&tmp); + + *pr = tmp.a; + *pg = tmp.b; + *pb = tmp.c; +} + +void +rgb2hsluv(double r, double g, double b, double* ph, double* ps, double* pl) +{ + Triplet tmp = { r, g, b }; + + rgb2xyz(&tmp); + xyz2luv(&tmp); + luv2lch(&tmp); + lch2hsluv(&tmp); + + *ph = tmp.a; + *ps = tmp.b; + *pl = tmp.c; +} + +void +rgb2hpluv(double r, double g, double b, double* ph, double* ps, double* pl) +{ + Triplet tmp = { r, g, b }; + + rgb2xyz(&tmp); + xyz2luv(&tmp); + luv2lch(&tmp); + lch2hpluv(&tmp); + + *ph = tmp.a; + *ps = tmp.b; + *pl = tmp.c; +} diff --git a/Monal/Classes/hsluv.h b/Monal/Classes/hsluv.h new file mode 100644 index 0000000..2d43d9f --- /dev/null +++ b/Monal/Classes/hsluv.h @@ -0,0 +1,93 @@ +/* + * HSLuv-C: Human-friendly HSL + * + * + * + * Copyright (c) 2015 Alexei Boronine (original idea, JavaScript implementation) + * Copyright (c) 2015 Roger Tallada (Obj-C implementation) + * Copyright (c) 2017 Martin Mitas (C implementation, based on Obj-C implementation) + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +#ifndef HSLUV_H +#define HSLUV_H + +#ifdef __cplusplus +extern "C" { +#endif + + +/** + * Convert HSLuv to RGB. + * + * @param h Hue. Between 0.0 and 360.0. + * @param s Saturation. Between 0.0 and 100.0. + * @param l Lightness. Between 0.0 and 100.0. + * @param[out] pr Red component. Between 0.0 and 1.0. + * @param[out] pg Green component. Between 0.0 and 1.0. + * @param[out] pb Blue component. Between 0.0 and 1.0. + */ +void hsluv2rgb(double h, double s, double l, double* pr, double* pg, double* pb); + +/** + * Convert RGB to HSLuv. + * + * @param r Red component. Between 0.0 and 1.0. + * @param g Green component. Between 0.0 and 1.0. + * @param b Blue component. Between 0.0 and 1.0. + * @param[out] ph Hue. Between 0.0 and 360.0. + * @param[out] ps Saturation. Between 0.0 and 100.0. + * @param[out] pl Lightness. Between 0.0 and 100.0. + */ +void rgb2hsluv(double r, double g, double b, double* ph, double* ps, double* pl); + +/** + * Convert HPLuv to RGB. + * + * @param h Hue. Between 0.0 and 360.0. + * @param s Saturation. Between 0.0 and 100.0. + * @param l Lightness. Between 0.0 and 100.0. + * @param[out] pr Red component. Between 0.0 and 1.0. + * @param[out] pg Green component. Between 0.0 and 1.0. + * @param[out] pb Blue component. Between 0.0 and 1.0. + */ +void hpluv2rgb(double h, double s, double l, double* pr, double* pg, double* pb); + +/** + * Convert RGB to HPLuv. + * + * @param r Red component. Between 0.0 and 1.0. + * @param g Green component. Between 0.0 and 1.0. + * @param b Blue component. Between 0.0 and 1.0. + * @param[out] ph Hue. Between 0.0 and 360.0. + * @param[out] ps Saturation. Between 0.0 and 100.0. + * @param[out] pl Lightness. Between 0.0 and 100.0. + * + * Note that HPLuv does not contain all the colors of RGB, so converting + * arbitrary RGB to it may generate invalid HPLuv colors. + */ +void rgb2hpluv(double r, double g, double b, double* ph, double* ps, double* pl); + + +#ifdef __cplusplus +} +#endif + +#endif /* HSLUV_H */ diff --git a/Monal/Classes/metamacros.h b/Monal/Classes/metamacros.h new file mode 100644 index 0000000..d9e4961 --- /dev/null +++ b/Monal/Classes/metamacros.h @@ -0,0 +1,667 @@ +/** + * Macros for metaprogramming + * ExtendedC + * + * Copyright (C) 2012 Justin Spahr-Summers + * Released under the MIT license + */ + +#ifndef EXTC_METAMACROS_H +#define EXTC_METAMACROS_H + + +/** + * Executes one or more expressions (which may have a void type, such as a call + * to a function that returns no value) and always returns true. + */ +#define metamacro_exprify(...) \ + ((__VA_ARGS__), true) + +/** + * Returns a string representation of VALUE after full macro expansion. + */ +#define metamacro_stringify(VALUE) \ + metamacro_stringify_(VALUE) + +/** + * Returns A and B concatenated after full macro expansion. + */ +#define metamacro_concat(A, B) \ + metamacro_concat_(A, B) + +/** + * Returns the Nth variadic argument (starting from zero). At least + * N + 1 variadic arguments must be given. N must be between zero and twenty, + * inclusive. + */ +#define metamacro_at(N, ...) \ + metamacro_concat(metamacro_at, N)(__VA_ARGS__) + +/** + * Returns the number of arguments (up to twenty) provided to the macro. At + * least one argument must be provided. + * + * Inspired by P99: http://p99.gforge.inria.fr + */ +#define metamacro_argcount(...) \ + metamacro_at(20, ##__VA_ARGS__, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0) + +/** + * Identical to #metamacro_foreach_cxt, except that no CONTEXT argument is + * given. Only the index and current argument will thus be passed to MACRO. + */ +#define metamacro_foreach(MACRO, SEP, ...) \ + metamacro_foreach_cxt(metamacro_foreach_iter, SEP, MACRO, __VA_ARGS__) + +/** + * For each consecutive variadic argument (up to twenty), MACRO is passed the + * zero-based index of the current argument, CONTEXT, and then the argument + * itself. The results of adjoining invocations of MACRO are then separated by + * SEP. + * + * Inspired by P99: http://p99.gforge.inria.fr + */ +#define metamacro_foreach_cxt(MACRO, SEP, CONTEXT, ...) \ + metamacro_concat(metamacro_foreach_cxt, metamacro_argcount(__VA_ARGS__))(MACRO, SEP, CONTEXT, __VA_ARGS__) + +/** + * Identical to #metamacro_foreach_cxt. This can be used when the former would + * fail due to recursive macro expansion. + */ +#define metamacro_foreach_cxt_recursive(MACRO, SEP, CONTEXT, ...) \ + metamacro_concat(metamacro_foreach_cxt_recursive, metamacro_argcount(__VA_ARGS__))(MACRO, SEP, CONTEXT, __VA_ARGS__) + +/** + * In consecutive order, appends each variadic argument (up to twenty) onto + * BASE. The resulting concatenations are then separated by SEP. + * + * This is primarily useful to manipulate a list of macro invocations into instead + * invoking a different, possibly related macro. + */ +#define metamacro_foreach_concat(BASE, SEP, ...) \ + metamacro_foreach_cxt(metamacro_foreach_concat_iter, SEP, BASE, __VA_ARGS__) + +/** + * Iterates COUNT times, each time invoking MACRO with the current index + * (starting at zero) and CONTEXT. The results of adjoining invocations of MACRO + * are then separated by SEP. + * + * COUNT must be an integer between zero and twenty, inclusive. + */ +#define metamacro_for_cxt(COUNT, MACRO, SEP, CONTEXT) \ + metamacro_concat(metamacro_for_cxt, COUNT)(MACRO, SEP, CONTEXT) + +/** + * Returns the first argument given. At least one argument must be provided. + * + * This is useful when implementing a variadic macro, where you may have only + * one variadic argument, but no way to retrieve it (for example, because \c ... + * always needs to match at least one argument). + * + * @code + +#define varmacro(...) \ + metamacro_head(__VA_ARGS__) + + * @endcode + */ +#define metamacro_head(...) \ + metamacro_head_(__VA_ARGS__, 0) + +/** + * Returns every argument except the first. At least two arguments must be + * provided. + */ +#define metamacro_tail(...) \ + metamacro_tail_(__VA_ARGS__) + +/** + * Returns the first N (up to twenty) variadic arguments as a new argument list. + * At least N variadic arguments must be provided. + */ +#define metamacro_take(N, ...) \ + metamacro_concat(metamacro_take, N)(__VA_ARGS__) + +/** + * Removes the first N (up to twenty) variadic arguments from the given argument + * list. At least N variadic arguments must be provided. + */ +#define metamacro_drop(N, ...) \ + metamacro_concat(metamacro_drop, N)(__VA_ARGS__) + +/** + * Decrements VAL, which must be a number between zero and twenty, inclusive. + * + * This is primarily useful when dealing with indexes and counts in + * metaprogramming. + */ +#define metamacro_dec(VAL) \ + metamacro_at(VAL, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19) + +/** + * Increments VAL, which must be a number between zero and twenty, inclusive. + * + * This is primarily useful when dealing with indexes and counts in + * metaprogramming. + */ +#define metamacro_inc(VAL) \ + metamacro_at(VAL, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21) + +/** + * If A is equal to B, the next argument list is expanded; otherwise, the + * argument list after that is expanded. A and B must be numbers between zero + * and twenty, inclusive. Additionally, B must be greater than or equal to A. + * + * @code + +// expands to true +metamacro_if_eq(0, 0)(true)(false) + +// expands to false +metamacro_if_eq(0, 1)(true)(false) + + * @endcode + * + * This is primarily useful when dealing with indexes and counts in + * metaprogramming. + */ +#define metamacro_if_eq(A, B) \ + metamacro_concat(metamacro_if_eq, A)(B) + +/** + * Identical to #metamacro_if_eq. This can be used when the former would fail + * due to recursive macro expansion. + */ +#define metamacro_if_eq_recursive(A, B) \ + metamacro_concat(metamacro_if_eq_recursive, A)(B) + +/** + * Returns 1 if N is an even number, or 0 otherwise. N must be between zero and + * twenty, inclusive. + * + * For the purposes of this test, zero is considered even. + */ +#define metamacro_is_even(N) \ + metamacro_at(N, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1) + +/** + * Returns the logical NOT of B, which must be the number zero or one. + */ +#define metamacro_not(B) \ + metamacro_at(B, 1, 0) + +// IMPLEMENTATION DETAILS FOLLOW! +// Do not write code that depends on anything below this line. +#define metamacro_stringify_(VALUE) # VALUE +#define metamacro_concat_(A, B) A ## B +#define metamacro_foreach_iter(INDEX, MACRO, ARG) MACRO(INDEX, ARG) +#define metamacro_head_(FIRST, ...) FIRST +#define metamacro_tail_(FIRST, ...) __VA_ARGS__ +#define metamacro_consume_(...) +#define metamacro_expand_(...) __VA_ARGS__ + +// implemented from scratch so that metamacro_concat() doesn't end up nesting +#define metamacro_foreach_concat_iter(INDEX, BASE, ARG) metamacro_foreach_concat_iter_(BASE, ARG) +#define metamacro_foreach_concat_iter_(BASE, ARG) BASE ## ARG + +// metamacro_at expansions +#define metamacro_at0(...) metamacro_head(__VA_ARGS__) +#define metamacro_at1(_0, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at2(_0, _1, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at3(_0, _1, _2, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at4(_0, _1, _2, _3, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at5(_0, _1, _2, _3, _4, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at6(_0, _1, _2, _3, _4, _5, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at7(_0, _1, _2, _3, _4, _5, _6, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at8(_0, _1, _2, _3, _4, _5, _6, _7, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at9(_0, _1, _2, _3, _4, _5, _6, _7, _8, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at10(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at11(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at12(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at13(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at14(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at15(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at16(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at17(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at18(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at19(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, ...) metamacro_head(__VA_ARGS__) +#define metamacro_at20(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, ...) metamacro_head(__VA_ARGS__) + +// metamacro_foreach_cxt expansions +#define metamacro_foreach_cxt0(MACRO, SEP, CONTEXT) +#define metamacro_foreach_cxt1(MACRO, SEP, CONTEXT, _0) MACRO(0, CONTEXT, _0) + +#define metamacro_foreach_cxt2(MACRO, SEP, CONTEXT, _0, _1) \ + metamacro_foreach_cxt1(MACRO, SEP, CONTEXT, _0) \ + SEP \ + MACRO(1, CONTEXT, _1) + +#define metamacro_foreach_cxt3(MACRO, SEP, CONTEXT, _0, _1, _2) \ + metamacro_foreach_cxt2(MACRO, SEP, CONTEXT, _0, _1) \ + SEP \ + MACRO(2, CONTEXT, _2) + +#define metamacro_foreach_cxt4(MACRO, SEP, CONTEXT, _0, _1, _2, _3) \ + metamacro_foreach_cxt3(MACRO, SEP, CONTEXT, _0, _1, _2) \ + SEP \ + MACRO(3, CONTEXT, _3) + +#define metamacro_foreach_cxt5(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4) \ + metamacro_foreach_cxt4(MACRO, SEP, CONTEXT, _0, _1, _2, _3) \ + SEP \ + MACRO(4, CONTEXT, _4) + +#define metamacro_foreach_cxt6(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5) \ + metamacro_foreach_cxt5(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4) \ + SEP \ + MACRO(5, CONTEXT, _5) + +#define metamacro_foreach_cxt7(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6) \ + metamacro_foreach_cxt6(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5) \ + SEP \ + MACRO(6, CONTEXT, _6) + +#define metamacro_foreach_cxt8(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7) \ + metamacro_foreach_cxt7(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6) \ + SEP \ + MACRO(7, CONTEXT, _7) + +#define metamacro_foreach_cxt9(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8) \ + metamacro_foreach_cxt8(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7) \ + SEP \ + MACRO(8, CONTEXT, _8) + +#define metamacro_foreach_cxt10(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ + metamacro_foreach_cxt9(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8) \ + SEP \ + MACRO(9, CONTEXT, _9) + +#define metamacro_foreach_cxt11(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \ + metamacro_foreach_cxt10(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ + SEP \ + MACRO(10, CONTEXT, _10) + +#define metamacro_foreach_cxt12(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \ + metamacro_foreach_cxt11(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \ + SEP \ + MACRO(11, CONTEXT, _11) + +#define metamacro_foreach_cxt13(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \ + metamacro_foreach_cxt12(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \ + SEP \ + MACRO(12, CONTEXT, _12) + +#define metamacro_foreach_cxt14(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \ + metamacro_foreach_cxt13(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \ + SEP \ + MACRO(13, CONTEXT, _13) + +#define metamacro_foreach_cxt15(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \ + metamacro_foreach_cxt14(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \ + SEP \ + MACRO(14, CONTEXT, _14) + +#define metamacro_foreach_cxt16(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \ + metamacro_foreach_cxt15(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \ + SEP \ + MACRO(15, CONTEXT, _15) + +#define metamacro_foreach_cxt17(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16) \ + metamacro_foreach_cxt16(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \ + SEP \ + MACRO(16, CONTEXT, _16) + +#define metamacro_foreach_cxt18(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17) \ + metamacro_foreach_cxt17(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16) \ + SEP \ + MACRO(17, CONTEXT, _17) + +#define metamacro_foreach_cxt19(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18) \ + metamacro_foreach_cxt18(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17) \ + SEP \ + MACRO(18, CONTEXT, _18) + +#define metamacro_foreach_cxt20(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19) \ + metamacro_foreach_cxt19(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18) \ + SEP \ + MACRO(19, CONTEXT, _19) + +// metamacro_foreach_cxt_recursive expansions +#define metamacro_foreach_cxt_recursive0(MACRO, SEP, CONTEXT) +#define metamacro_foreach_cxt_recursive1(MACRO, SEP, CONTEXT, _0) MACRO(0, CONTEXT, _0) + +#define metamacro_foreach_cxt_recursive2(MACRO, SEP, CONTEXT, _0, _1) \ + metamacro_foreach_cxt_recursive1(MACRO, SEP, CONTEXT, _0) \ + SEP \ + MACRO(1, CONTEXT, _1) + +#define metamacro_foreach_cxt_recursive3(MACRO, SEP, CONTEXT, _0, _1, _2) \ + metamacro_foreach_cxt_recursive2(MACRO, SEP, CONTEXT, _0, _1) \ + SEP \ + MACRO(2, CONTEXT, _2) + +#define metamacro_foreach_cxt_recursive4(MACRO, SEP, CONTEXT, _0, _1, _2, _3) \ + metamacro_foreach_cxt_recursive3(MACRO, SEP, CONTEXT, _0, _1, _2) \ + SEP \ + MACRO(3, CONTEXT, _3) + +#define metamacro_foreach_cxt_recursive5(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4) \ + metamacro_foreach_cxt_recursive4(MACRO, SEP, CONTEXT, _0, _1, _2, _3) \ + SEP \ + MACRO(4, CONTEXT, _4) + +#define metamacro_foreach_cxt_recursive6(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5) \ + metamacro_foreach_cxt_recursive5(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4) \ + SEP \ + MACRO(5, CONTEXT, _5) + +#define metamacro_foreach_cxt_recursive7(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6) \ + metamacro_foreach_cxt_recursive6(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5) \ + SEP \ + MACRO(6, CONTEXT, _6) + +#define metamacro_foreach_cxt_recursive8(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7) \ + metamacro_foreach_cxt_recursive7(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6) \ + SEP \ + MACRO(7, CONTEXT, _7) + +#define metamacro_foreach_cxt_recursive9(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8) \ + metamacro_foreach_cxt_recursive8(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7) \ + SEP \ + MACRO(8, CONTEXT, _8) + +#define metamacro_foreach_cxt_recursive10(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ + metamacro_foreach_cxt_recursive9(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8) \ + SEP \ + MACRO(9, CONTEXT, _9) + +#define metamacro_foreach_cxt_recursive11(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \ + metamacro_foreach_cxt_recursive10(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ + SEP \ + MACRO(10, CONTEXT, _10) + +#define metamacro_foreach_cxt_recursive12(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \ + metamacro_foreach_cxt_recursive11(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \ + SEP \ + MACRO(11, CONTEXT, _11) + +#define metamacro_foreach_cxt_recursive13(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \ + metamacro_foreach_cxt_recursive12(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \ + SEP \ + MACRO(12, CONTEXT, _12) + +#define metamacro_foreach_cxt_recursive14(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \ + metamacro_foreach_cxt_recursive13(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \ + SEP \ + MACRO(13, CONTEXT, _13) + +#define metamacro_foreach_cxt_recursive15(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \ + metamacro_foreach_cxt_recursive14(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \ + SEP \ + MACRO(14, CONTEXT, _14) + +#define metamacro_foreach_cxt_recursive16(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \ + metamacro_foreach_cxt_recursive15(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \ + SEP \ + MACRO(15, CONTEXT, _15) + +#define metamacro_foreach_cxt_recursive17(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16) \ + metamacro_foreach_cxt_recursive16(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \ + SEP \ + MACRO(16, CONTEXT, _16) + +#define metamacro_foreach_cxt_recursive18(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17) \ + metamacro_foreach_cxt_recursive17(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16) \ + SEP \ + MACRO(17, CONTEXT, _17) + +#define metamacro_foreach_cxt_recursive19(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18) \ + metamacro_foreach_cxt_recursive18(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17) \ + SEP \ + MACRO(18, CONTEXT, _18) + +#define metamacro_foreach_cxt_recursive20(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19) \ + metamacro_foreach_cxt_recursive19(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18) \ + SEP \ + MACRO(19, CONTEXT, _19) + +// metamacro_for_cxt expansions +#define metamacro_for_cxt0(MACRO, SEP, CONTEXT) +#define metamacro_for_cxt1(MACRO, SEP, CONTEXT) MACRO(0, CONTEXT) + +#define metamacro_for_cxt2(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt1(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(1, CONTEXT) + +#define metamacro_for_cxt3(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt2(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(2, CONTEXT) + +#define metamacro_for_cxt4(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt3(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(3, CONTEXT) + +#define metamacro_for_cxt5(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt4(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(4, CONTEXT) + +#define metamacro_for_cxt6(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt5(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(5, CONTEXT) + +#define metamacro_for_cxt7(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt6(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(6, CONTEXT) + +#define metamacro_for_cxt8(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt7(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(7, CONTEXT) + +#define metamacro_for_cxt9(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt8(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(8, CONTEXT) + +#define metamacro_for_cxt10(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt9(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(9, CONTEXT) + +#define metamacro_for_cxt11(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt10(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(10, CONTEXT) + +#define metamacro_for_cxt12(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt11(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(11, CONTEXT) + +#define metamacro_for_cxt13(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt12(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(12, CONTEXT) + +#define metamacro_for_cxt14(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt13(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(13, CONTEXT) + +#define metamacro_for_cxt15(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt14(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(14, CONTEXT) + +#define metamacro_for_cxt16(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt15(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(15, CONTEXT) + +#define metamacro_for_cxt17(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt16(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(16, CONTEXT) + +#define metamacro_for_cxt18(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt17(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(17, CONTEXT) + +#define metamacro_for_cxt19(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt18(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(18, CONTEXT) + +#define metamacro_for_cxt20(MACRO, SEP, CONTEXT) \ + metamacro_for_cxt19(MACRO, SEP, CONTEXT) \ + SEP \ + MACRO(19, CONTEXT) + +// metamacro_if_eq expansions +#define metamacro_if_eq0(VALUE) \ + metamacro_concat(metamacro_if_eq0_, VALUE) + +#define metamacro_if_eq0_0(...) __VA_ARGS__ metamacro_consume_ +#define metamacro_if_eq0_1(...) metamacro_expand_ +#define metamacro_if_eq0_2(...) metamacro_expand_ +#define metamacro_if_eq0_3(...) metamacro_expand_ +#define metamacro_if_eq0_4(...) metamacro_expand_ +#define metamacro_if_eq0_5(...) metamacro_expand_ +#define metamacro_if_eq0_6(...) metamacro_expand_ +#define metamacro_if_eq0_7(...) metamacro_expand_ +#define metamacro_if_eq0_8(...) metamacro_expand_ +#define metamacro_if_eq0_9(...) metamacro_expand_ +#define metamacro_if_eq0_10(...) metamacro_expand_ +#define metamacro_if_eq0_11(...) metamacro_expand_ +#define metamacro_if_eq0_12(...) metamacro_expand_ +#define metamacro_if_eq0_13(...) metamacro_expand_ +#define metamacro_if_eq0_14(...) metamacro_expand_ +#define metamacro_if_eq0_15(...) metamacro_expand_ +#define metamacro_if_eq0_16(...) metamacro_expand_ +#define metamacro_if_eq0_17(...) metamacro_expand_ +#define metamacro_if_eq0_18(...) metamacro_expand_ +#define metamacro_if_eq0_19(...) metamacro_expand_ +#define metamacro_if_eq0_20(...) metamacro_expand_ + +#define metamacro_if_eq1(VALUE) metamacro_if_eq0(metamacro_dec(VALUE)) +#define metamacro_if_eq2(VALUE) metamacro_if_eq1(metamacro_dec(VALUE)) +#define metamacro_if_eq3(VALUE) metamacro_if_eq2(metamacro_dec(VALUE)) +#define metamacro_if_eq4(VALUE) metamacro_if_eq3(metamacro_dec(VALUE)) +#define metamacro_if_eq5(VALUE) metamacro_if_eq4(metamacro_dec(VALUE)) +#define metamacro_if_eq6(VALUE) metamacro_if_eq5(metamacro_dec(VALUE)) +#define metamacro_if_eq7(VALUE) metamacro_if_eq6(metamacro_dec(VALUE)) +#define metamacro_if_eq8(VALUE) metamacro_if_eq7(metamacro_dec(VALUE)) +#define metamacro_if_eq9(VALUE) metamacro_if_eq8(metamacro_dec(VALUE)) +#define metamacro_if_eq10(VALUE) metamacro_if_eq9(metamacro_dec(VALUE)) +#define metamacro_if_eq11(VALUE) metamacro_if_eq10(metamacro_dec(VALUE)) +#define metamacro_if_eq12(VALUE) metamacro_if_eq11(metamacro_dec(VALUE)) +#define metamacro_if_eq13(VALUE) metamacro_if_eq12(metamacro_dec(VALUE)) +#define metamacro_if_eq14(VALUE) metamacro_if_eq13(metamacro_dec(VALUE)) +#define metamacro_if_eq15(VALUE) metamacro_if_eq14(metamacro_dec(VALUE)) +#define metamacro_if_eq16(VALUE) metamacro_if_eq15(metamacro_dec(VALUE)) +#define metamacro_if_eq17(VALUE) metamacro_if_eq16(metamacro_dec(VALUE)) +#define metamacro_if_eq18(VALUE) metamacro_if_eq17(metamacro_dec(VALUE)) +#define metamacro_if_eq19(VALUE) metamacro_if_eq18(metamacro_dec(VALUE)) +#define metamacro_if_eq20(VALUE) metamacro_if_eq19(metamacro_dec(VALUE)) + +// metamacro_if_eq_recursive expansions +#define metamacro_if_eq_recursive0(VALUE) \ + metamacro_concat(metamacro_if_eq_recursive0_, VALUE) + +#define metamacro_if_eq_recursive0_0(...) __VA_ARGS__ metamacro_consume_ +#define metamacro_if_eq_recursive0_1(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_2(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_3(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_4(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_5(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_6(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_7(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_8(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_9(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_10(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_11(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_12(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_13(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_14(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_15(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_16(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_17(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_18(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_19(...) metamacro_expand_ +#define metamacro_if_eq_recursive0_20(...) metamacro_expand_ + +#define metamacro_if_eq_recursive1(VALUE) metamacro_if_eq_recursive0(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive2(VALUE) metamacro_if_eq_recursive1(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive3(VALUE) metamacro_if_eq_recursive2(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive4(VALUE) metamacro_if_eq_recursive3(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive5(VALUE) metamacro_if_eq_recursive4(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive6(VALUE) metamacro_if_eq_recursive5(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive7(VALUE) metamacro_if_eq_recursive6(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive8(VALUE) metamacro_if_eq_recursive7(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive9(VALUE) metamacro_if_eq_recursive8(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive10(VALUE) metamacro_if_eq_recursive9(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive11(VALUE) metamacro_if_eq_recursive10(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive12(VALUE) metamacro_if_eq_recursive11(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive13(VALUE) metamacro_if_eq_recursive12(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive14(VALUE) metamacro_if_eq_recursive13(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive15(VALUE) metamacro_if_eq_recursive14(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive16(VALUE) metamacro_if_eq_recursive15(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive17(VALUE) metamacro_if_eq_recursive16(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive18(VALUE) metamacro_if_eq_recursive17(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive19(VALUE) metamacro_if_eq_recursive18(metamacro_dec(VALUE)) +#define metamacro_if_eq_recursive20(VALUE) metamacro_if_eq_recursive19(metamacro_dec(VALUE)) + +// metamacro_take expansions +#define metamacro_take0(...) +#define metamacro_take1(...) metamacro_head(__VA_ARGS__) +#define metamacro_take2(...) metamacro_head(__VA_ARGS__), metamacro_take1(metamacro_tail(__VA_ARGS__)) +#define metamacro_take3(...) metamacro_head(__VA_ARGS__), metamacro_take2(metamacro_tail(__VA_ARGS__)) +#define metamacro_take4(...) metamacro_head(__VA_ARGS__), metamacro_take3(metamacro_tail(__VA_ARGS__)) +#define metamacro_take5(...) metamacro_head(__VA_ARGS__), metamacro_take4(metamacro_tail(__VA_ARGS__)) +#define metamacro_take6(...) metamacro_head(__VA_ARGS__), metamacro_take5(metamacro_tail(__VA_ARGS__)) +#define metamacro_take7(...) metamacro_head(__VA_ARGS__), metamacro_take6(metamacro_tail(__VA_ARGS__)) +#define metamacro_take8(...) metamacro_head(__VA_ARGS__), metamacro_take7(metamacro_tail(__VA_ARGS__)) +#define metamacro_take9(...) metamacro_head(__VA_ARGS__), metamacro_take8(metamacro_tail(__VA_ARGS__)) +#define metamacro_take10(...) metamacro_head(__VA_ARGS__), metamacro_take9(metamacro_tail(__VA_ARGS__)) +#define metamacro_take11(...) metamacro_head(__VA_ARGS__), metamacro_take10(metamacro_tail(__VA_ARGS__)) +#define metamacro_take12(...) metamacro_head(__VA_ARGS__), metamacro_take11(metamacro_tail(__VA_ARGS__)) +#define metamacro_take13(...) metamacro_head(__VA_ARGS__), metamacro_take12(metamacro_tail(__VA_ARGS__)) +#define metamacro_take14(...) metamacro_head(__VA_ARGS__), metamacro_take13(metamacro_tail(__VA_ARGS__)) +#define metamacro_take15(...) metamacro_head(__VA_ARGS__), metamacro_take14(metamacro_tail(__VA_ARGS__)) +#define metamacro_take16(...) metamacro_head(__VA_ARGS__), metamacro_take15(metamacro_tail(__VA_ARGS__)) +#define metamacro_take17(...) metamacro_head(__VA_ARGS__), metamacro_take16(metamacro_tail(__VA_ARGS__)) +#define metamacro_take18(...) metamacro_head(__VA_ARGS__), metamacro_take17(metamacro_tail(__VA_ARGS__)) +#define metamacro_take19(...) metamacro_head(__VA_ARGS__), metamacro_take18(metamacro_tail(__VA_ARGS__)) +#define metamacro_take20(...) metamacro_head(__VA_ARGS__), metamacro_take19(metamacro_tail(__VA_ARGS__)) + +// metamacro_drop expansions +#define metamacro_drop0(...) __VA_ARGS__ +#define metamacro_drop1(...) metamacro_tail(__VA_ARGS__) +#define metamacro_drop2(...) metamacro_drop1(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop3(...) metamacro_drop2(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop4(...) metamacro_drop3(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop5(...) metamacro_drop4(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop6(...) metamacro_drop5(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop7(...) metamacro_drop6(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop8(...) metamacro_drop7(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop9(...) metamacro_drop8(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop10(...) metamacro_drop9(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop11(...) metamacro_drop10(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop12(...) metamacro_drop11(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop13(...) metamacro_drop12(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop14(...) metamacro_drop13(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop15(...) metamacro_drop14(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop16(...) metamacro_drop15(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop17(...) metamacro_drop16(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop18(...) metamacro_drop17(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop19(...) metamacro_drop18(metamacro_tail(__VA_ARGS__)) +#define metamacro_drop20(...) metamacro_drop19(metamacro_tail(__VA_ARGS__)) + +#endif diff --git a/Monal/Classes/snprintf.m b/Monal/Classes/snprintf.m new file mode 100644 index 0000000..6ebcc4d --- /dev/null +++ b/Monal/Classes/snprintf.m @@ -0,0 +1,2157 @@ +/* + * Copyright (c) 1995 Patrick Powell. + * + * This code is based on code written by Patrick Powell . + * It may be used for any purpose as long as this notice remains intact on all + * source code distributions. + */ + +/* + * Copyright (c) 2008 Holger Weiss. + * + * This version of the code is maintained by Holger Weiss . + * My changes to the code may freely be used, modified and/or redistributed for + * any purpose. It would be nice if additions and fixes to this file (including + * trivial code cleanups) would be sent back in order to let me include them in + * the version available at . + * However, this is not a requirement for using or redistributing (possibly + * modified) versions of this file, nor is leaving this notice intact mandatory. + */ + +/* + * History + * + * 2008-01-20 Holger Weiss for C99-snprintf 1.1: + * + * Fixed the detection of infinite floating point values on IRIX (and + * possibly other systems) and applied another few minor cleanups. + * + * 2008-01-06 Holger Weiss for C99-snprintf 1.0: + * + * Added a lot of new features, fixed many bugs, and incorporated various + * improvements done by Andrew Tridgell , Russ Allbery + * , Hrvoje Niksic , Damien Miller + * , and others for the Samba, INN, Wget, and OpenSSH + * projects. The additions include: support the "e", "E", "g", "G", and + * "F" conversion specifiers (and use conversion style "f" or "F" for the + * still unsupported "a" and "A" specifiers); support the "hh", "ll", "j", + * "t", and "z" length modifiers; support the "#" flag and the (non-C99) + * "'" flag; use localeconv(3) (if available) to get both the current + * locale's decimal point character and the separator between groups of + * digits; fix the handling of various corner cases of field width and + * precision specifications; fix various floating point conversion bugs; + * handle infinite and NaN floating point values; don't attempt to write to + * the output buffer (which may be NULL) if a size of zero was specified; + * check for integer overflow of the field width, precision, and return + * values and during the floating point conversion; use the OUTCHAR() macro + * instead of a function for better performance; provide asprintf(3) and + * vasprintf(3) functions; add new test cases. The replacement functions + * have been renamed to use an "rpl_" prefix, the function calls in the + * main project (and in this file) must be redefined accordingly for each + * replacement function which is needed (by using Autoconf or other means). + * Various other minor improvements have been applied and the coding style + * was cleaned up for consistency. + * + * 2007-07-23 Holger Weiss for Mutt 1.5.13: + * + * C99 compliant snprintf(3) and vsnprintf(3) functions return the number + * of characters that would have been written to a sufficiently sized + * buffer (excluding the '\0'). The original code simply returned the + * length of the resulting output string, so that's been fixed. + * + * 1998-03-05 Michael Elkins for Mutt 0.90.8: + * + * The original code assumed that both snprintf(3) and vsnprintf(3) were + * missing. Some systems only have snprintf(3) but not vsnprintf(3), so + * the code is now broken down under HAVE_SNPRINTF and HAVE_VSNPRINTF. + * + * 1998-01-27 Thomas Roessler for Mutt 0.89i: + * + * The PGP code was using unsigned hexadecimal formats. Unfortunately, + * unsigned formats simply didn't work. + * + * 1997-10-22 Brandon Long for Mutt 0.87.1: + * + * Ok, added some minimal floating point support, which means this probably + * requires libm on most operating systems. Don't yet support the exponent + * (e,E) and sigfig (g,G). Also, fmtint() was pretty badly broken, it just + * wasn't being exercised in ways which showed it, so that's been fixed. + * Also, formatted the code to Mutt conventions, and removed dead code left + * over from the original. Also, there is now a builtin-test, run with: + * gcc -DTEST_SNPRINTF -o snprintf snprintf.c -lm && ./snprintf + * + * 2996-09-15 Brandon Long for Mutt 0.43: + * + * This was ugly. It is still ugly. I opted out of floating point + * numbers, but the formatter understands just about everything from the + * normal C string format, at least as far as I can tell from the Solaris + * 2.5 printf(3S) man page. + */ + +/* + * ToDo + * + * - Add wide character support. + * - Add support for "%a" and "%A" conversions. + * - Create test routines which predefine the expected results. Our test cases + * usually expose bugs in system implementations rather than in ours :-) + */ + +#import + +//swap comments here to print debug statements +//#import "MLConstants.h" +#define DDLogError(...) + +//make sure we always use the code from this file +#undef snprintf +#undef vsnprintf +#undef asprintf +#undef vasprintf +#define snprintf rpl_snprintf +#define vsnprintf rpl_vsnprintf +#define asprintf rpl_asprintf +#define vasprintf rpl_vasprintf + +//define every external utility function as present, but none of the printf-type functions: +//we want to always use our internal functions here +#define HAVE_STDARG_H 1 +//#define HAVE_VSNPRINTF 1 +//#define HAVE_SNPRINTF 1 +//#define HAVE_VASPRINTF 1 +//#define HAVE_ASPRINTF 1 +#define HAVE_STDARG_H 1 +#define HAVE_STDDEF_H 1 +#define HAVE_STDINT_H 1 +#define HAVE_STDLIB_H 1 +#define HAVE_FLOAT_H 1 +#define HAVE_INTTYPES_H 1 +#define HAVE_LOCALE_H 1 +#define HAVE_LOCALECONV 1 +#define HAVE_LCONV_DECIMAL_POINT 1 +#define HAVE_LCONV_THOUSANDS_SEP 1 +#define HAVE_LONG_DOUBLE 1 +#define HAVE_LONG_LONG_INT 1 +#define HAVE_UNSIGNED_LONG_LONG_INT 1 +#define HAVE_INTMAX_T 1 +#define HAVE_UINTMAX_T 1 +#define HAVE_UINTPTR_T 1 +#define HAVE_PTRDIFF_T 1 +#define HAVE_VA_COPY 1 +#define HAVE___VA_COPY 1 + +/* + * Usage + * + * 1) The following preprocessor macros should be defined to 1 if the feature or + * file in question is available on the target system (by using Autoconf or + * other means), though basic functionality should be available as long as + * HAVE_STDARG_H and HAVE_STDLIB_H are defined correctly: + * + * HAVE_VSNPRINTF + * HAVE_SNPRINTF + * HAVE_VASPRINTF + * HAVE_ASPRINTF + * HAVE_STDARG_H + * HAVE_STDDEF_H + * HAVE_STDINT_H + * HAVE_STDLIB_H + * HAVE_FLOAT_H + * HAVE_INTTYPES_H + * HAVE_LOCALE_H + * HAVE_LOCALECONV + * HAVE_LCONV_DECIMAL_POINT + * HAVE_LCONV_THOUSANDS_SEP + * HAVE_LONG_DOUBLE + * HAVE_LONG_LONG_INT + * HAVE_UNSIGNED_LONG_LONG_INT + * HAVE_INTMAX_T + * HAVE_UINTMAX_T + * HAVE_UINTPTR_T + * HAVE_PTRDIFF_T + * HAVE_VA_COPY + * HAVE___VA_COPY + * + * 2) The calls to the functions which should be replaced must be redefined + * throughout the project files (by using Autoconf or other means): + * + * #define vsnprintf rpl_vsnprintf + * #define snprintf rpl_snprintf + * #define vasprintf rpl_vasprintf + * #define asprintf rpl_asprintf + * + * 3) The required replacement functions should be declared in some header file + * included throughout the project files: + * + * #if HAVE_CONFIG_H + * #include + * #endif + * #if HAVE_STDARG_H + * #include + * #if !HAVE_VSNPRINTF + * int rpl_vsnprintf(char *, size_t, const char *, va_list); + * #endif + * #if !HAVE_SNPRINTF + * int rpl_snprintf(char *, size_t, const char *, ...); + * #endif + * #if !HAVE_VASPRINTF + * int rpl_vasprintf(char **, const char *, va_list); + * #endif + * #if !HAVE_ASPRINTF + * int rpl_asprintf(char **, const char *, ...); + * #endif + * #endif + * + * Autoconf macros for handling step 1 and step 2 are available at + * . + */ + +#if HAVE_CONFIG_H +#include +#endif /* HAVE_CONFIG_H */ + +#if TEST_SNPRINTF +#include /* For pow(3), NAN, and INFINITY. */ +#include /* For strcmp(3). */ +#if defined(__NetBSD__) || \ + defined(__FreeBSD__) || \ + defined(__OpenBSD__) || \ + defined(__NeXT__) || \ + defined(__bsd__) +#define OS_BSD 1 +#elif defined(sgi) || defined(__sgi) +#ifndef __c99 +#define __c99 /* Force C99 mode to get included on IRIX 6.5.30. */ +#endif /* !defined(__c99) */ +#define OS_IRIX 1 +#define OS_SYSV 1 +#elif defined(__svr4__) +#define OS_SYSV 1 +#elif defined(__linux__) +#define OS_LINUX 1 +#endif /* defined(__NetBSD__) || defined(__FreeBSD__) || [...] */ +#if HAVE_CONFIG_H /* Undefine definitions possibly done in config.h. */ +#ifdef HAVE_SNPRINTF +#undef HAVE_SNPRINTF +#endif /* defined(HAVE_SNPRINTF) */ +#ifdef HAVE_VSNPRINTF +#undef HAVE_VSNPRINTF +#endif /* defined(HAVE_VSNPRINTF) */ +#ifdef HAVE_ASPRINTF +#undef HAVE_ASPRINTF +#endif /* defined(HAVE_ASPRINTF) */ +#ifdef HAVE_VASPRINTF +#undef HAVE_VASPRINTF +#endif /* defined(HAVE_VASPRINTF) */ +#ifdef snprintf +#undef snprintf +#endif /* defined(snprintf) */ +#ifdef vsnprintf +#undef vsnprintf +#endif /* defined(vsnprintf) */ +#ifdef asprintf +#undef asprintf +#endif /* defined(asprintf) */ +#ifdef vasprintf +#undef vasprintf +#endif /* defined(vasprintf) */ +#else /* By default, we assume a modern system for testing. */ +#ifndef HAVE_STDARG_H +#define HAVE_STDARG_H 1 +#endif /* HAVE_STDARG_H */ +#ifndef HAVE_STDDEF_H +#define HAVE_STDDEF_H 1 +#endif /* HAVE_STDDEF_H */ +#ifndef HAVE_STDINT_H +#define HAVE_STDINT_H 1 +#endif /* HAVE_STDINT_H */ +#ifndef HAVE_STDLIB_H +#define HAVE_STDLIB_H 1 +#endif /* HAVE_STDLIB_H */ +#ifndef HAVE_FLOAT_H +#define HAVE_FLOAT_H 1 +#endif /* HAVE_FLOAT_H */ +#ifndef HAVE_INTTYPES_H +#define HAVE_INTTYPES_H 1 +#endif /* HAVE_INTTYPES_H */ +#ifndef HAVE_LOCALE_H +#define HAVE_LOCALE_H 1 +#endif /* HAVE_LOCALE_H */ +#ifndef HAVE_LOCALECONV +#define HAVE_LOCALECONV 1 +#endif /* !defined(HAVE_LOCALECONV) */ +#ifndef HAVE_LCONV_DECIMAL_POINT +#define HAVE_LCONV_DECIMAL_POINT 1 +#endif /* HAVE_LCONV_DECIMAL_POINT */ +#ifndef HAVE_LCONV_THOUSANDS_SEP +#define HAVE_LCONV_THOUSANDS_SEP 1 +#endif /* HAVE_LCONV_THOUSANDS_SEP */ +#ifndef HAVE_LONG_DOUBLE +#define HAVE_LONG_DOUBLE 1 +#endif /* !defined(HAVE_LONG_DOUBLE) */ +#ifndef HAVE_LONG_LONG_INT +#define HAVE_LONG_LONG_INT 1 +#endif /* !defined(HAVE_LONG_LONG_INT) */ +#ifndef HAVE_UNSIGNED_LONG_LONG_INT +#define HAVE_UNSIGNED_LONG_LONG_INT 1 +#endif /* !defined(HAVE_UNSIGNED_LONG_LONG_INT) */ +#ifndef HAVE_INTMAX_T +#define HAVE_INTMAX_T 1 +#endif /* !defined(HAVE_INTMAX_T) */ +#ifndef HAVE_UINTMAX_T +#define HAVE_UINTMAX_T 1 +#endif /* !defined(HAVE_UINTMAX_T) */ +#ifndef HAVE_UINTPTR_T +#define HAVE_UINTPTR_T 1 +#endif /* !defined(HAVE_UINTPTR_T) */ +#ifndef HAVE_PTRDIFF_T +#define HAVE_PTRDIFF_T 1 +#endif /* !defined(HAVE_PTRDIFF_T) */ +#ifndef HAVE_VA_COPY +#define HAVE_VA_COPY 1 +#endif /* !defined(HAVE_VA_COPY) */ +#ifndef HAVE___VA_COPY +#define HAVE___VA_COPY 1 +#endif /* !defined(HAVE___VA_COPY) */ +#endif /* HAVE_CONFIG_H */ +#define snprintf rpl_snprintf +#define vsnprintf rpl_vsnprintf +#define asprintf rpl_asprintf +#define vasprintf rpl_vasprintf +#endif /* TEST_SNPRINTF */ + +#if !HAVE_SNPRINTF || !HAVE_VSNPRINTF || !HAVE_ASPRINTF || !HAVE_VASPRINTF +#include /* For NULL, size_t, vsnprintf(3), and vasprintf(3). */ +#ifdef VA_START +#undef VA_START +#endif /* defined(VA_START) */ +#ifdef VA_SHIFT +#undef VA_SHIFT +#endif /* defined(VA_SHIFT) */ +#if HAVE_STDARG_H +#include +#define VA_START(ap, last) va_start(ap, last) +#define VA_SHIFT(ap, value, type) /* No-op for ANSI C. */ +#else /* Assume is available. */ +#include +#define VA_START(ap, last) va_start(ap) /* "last" is ignored. */ +#define VA_SHIFT(ap, value, type) value = va_arg(ap, type) +#endif /* HAVE_STDARG_H */ + +#if !HAVE_VASPRINTF +#if HAVE_STDLIB_H +#include /* For malloc(3). */ +#endif /* HAVE_STDLIB_H */ +#ifdef VA_COPY +#undef VA_COPY +#endif /* defined(VA_COPY) */ +#ifdef VA_END_COPY +#undef VA_END_COPY +#endif /* defined(VA_END_COPY) */ +#if HAVE_VA_COPY +#define VA_COPY(dest, src) va_copy(dest, src) +#define VA_END_COPY(ap) va_end(ap) +#elif HAVE___VA_COPY +#define VA_COPY(dest, src) __va_copy(dest, src) +#define VA_END_COPY(ap) va_end(ap) +#else +#define VA_COPY(dest, src) (void)mymemcpy(&dest, &src, sizeof(va_list)) +#define VA_END_COPY(ap) /* No-op. */ +#define NEED_MYMEMCPY 1 +static void *mymemcpy(void *, void *, size_t); +#endif /* HAVE_VA_COPY */ +#endif /* !HAVE_VASPRINTF */ + +#if !HAVE_VSNPRINTF +#include /* For ERANGE and errno. */ +#include /* For *_MAX. */ +#if HAVE_FLOAT_H +#include /* For *DBL_{MIN,MAX}_10_EXP. */ +#endif /* HAVE_FLOAT_H */ +#if HAVE_INTTYPES_H +#include /* For intmax_t (if not defined in ). */ +#endif /* HAVE_INTTYPES_H */ +#if HAVE_LOCALE_H +#include /* For localeconv(3). */ +#endif /* HAVE_LOCALE_H */ +#if HAVE_STDDEF_H +#include /* For ptrdiff_t. */ +#endif /* HAVE_STDDEF_H */ +#if HAVE_STDINT_H +#include /* For intmax_t. */ +#endif /* HAVE_STDINT_H */ + +/* Support for unsigned long long int. We may also need ULLONG_MAX. */ +#ifndef ULONG_MAX /* We may need ULONG_MAX as a fallback. */ +#ifdef UINT_MAX +#define ULONG_MAX UINT_MAX +#else +#define ULONG_MAX INT_MAX +#endif /* defined(UINT_MAX) */ +#endif /* !defined(ULONG_MAX) */ +#ifdef ULLONG +#undef ULLONG +#endif /* defined(ULLONG) */ +#if HAVE_UNSIGNED_LONG_LONG_INT +#define ULLONG unsigned long long int +#ifndef ULLONG_MAX +#define ULLONG_MAX ULONG_MAX +#endif /* !defined(ULLONG_MAX) */ +#else +#define ULLONG unsigned long int +#ifdef ULLONG_MAX +#undef ULLONG_MAX +#endif /* defined(ULLONG_MAX) */ +#define ULLONG_MAX ULONG_MAX +#endif /* HAVE_LONG_LONG_INT */ + +/* Support for uintmax_t. We also need UINTMAX_MAX. */ +#ifdef UINTMAX_T +#undef UINTMAX_T +#endif /* defined(UINTMAX_T) */ +#if HAVE_UINTMAX_T || defined(uintmax_t) +#define UINTMAX_T uintmax_t +#ifndef UINTMAX_MAX +#define UINTMAX_MAX ULLONG_MAX +#endif /* !defined(UINTMAX_MAX) */ +#else +#define UINTMAX_T ULLONG +#ifdef UINTMAX_MAX +#undef UINTMAX_MAX +#endif /* defined(UINTMAX_MAX) */ +#define UINTMAX_MAX ULLONG_MAX +#endif /* HAVE_UINTMAX_T || defined(uintmax_t) */ + +/* Support for long double. */ +#ifndef LDOUBLE +#if HAVE_LONG_DOUBLE +#define LDOUBLE long double +#define LDOUBLE_MIN_10_EXP LDBL_MIN_10_EXP +#define LDOUBLE_MAX_10_EXP LDBL_MAX_10_EXP +#else +#define LDOUBLE double +#define LDOUBLE_MIN_10_EXP DBL_MIN_10_EXP +#define LDOUBLE_MAX_10_EXP DBL_MAX_10_EXP +#endif /* HAVE_LONG_DOUBLE */ +#endif /* !defined(LDOUBLE) */ + +/* Support for long long int. */ +#ifndef LLONG +#if HAVE_LONG_LONG_INT +#define LLONG long long int +#else +#define LLONG long int +#endif /* HAVE_LONG_LONG_INT */ +#endif /* !defined(LLONG) */ + +/* Support for intmax_t. */ +#ifndef INTMAX_T +#if HAVE_INTMAX_T || defined(intmax_t) +#define INTMAX_T intmax_t +#else +#define INTMAX_T LLONG +#endif /* HAVE_INTMAX_T || defined(intmax_t) */ +#endif /* !defined(INTMAX_T) */ + +/* Support for uintptr_t. */ +#ifndef UINTPTR_T +#if HAVE_UINTPTR_T || defined(uintptr_t) +#define UINTPTR_T uintptr_t +#else +#define UINTPTR_T unsigned long int +#endif /* HAVE_UINTPTR_T || defined(uintptr_t) */ +#endif /* !defined(UINTPTR_T) */ + +/* Support for ptrdiff_t. */ +#ifndef PTRDIFF_T +#if HAVE_PTRDIFF_T || defined(ptrdiff_t) +#define PTRDIFF_T ptrdiff_t +#else +#define PTRDIFF_T long int +#endif /* HAVE_PTRDIFF_T || defined(ptrdiff_t) */ +#endif /* !defined(PTRDIFF_T) */ + +/* + * We need an unsigned integer type corresponding to ptrdiff_t (cf. C99: + * 7.19.6.1, 7). However, we'll simply use PTRDIFF_T and convert it to an + * unsigned type if necessary. This should work just fine in practice. + */ +#ifndef UPTRDIFF_T +#define UPTRDIFF_T PTRDIFF_T +#endif /* !defined(UPTRDIFF_T) */ + +/* + * We need a signed integer type corresponding to size_t (cf. C99: 7.19.6.1, 7). + * However, we'll simply use size_t and convert it to a signed type if + * necessary. This should work just fine in practice. + */ +#ifndef SSIZE_T +#define SSIZE_T size_t +#endif /* !defined(SSIZE_T) */ + +/* Either ERANGE or E2BIG should be available everywhere. */ +#ifndef ERANGE +#define ERANGE E2BIG +#endif /* !defined(ERANGE) */ +#ifndef EOVERFLOW +#define EOVERFLOW ERANGE +#endif /* !defined(EOVERFLOW) */ + +/* + * Buffer size to hold the octal string representation of UINT128_MAX without + * nul-termination ("3777777777777777777777777777777777777777777"). + */ +#ifdef MAX_CONVERT_LENGTH +#undef MAX_CONVERT_LENGTH +#endif /* defined(MAX_CONVERT_LENGTH) */ +#define MAX_CONVERT_LENGTH 43 + +/* Format read states. */ +#define PRINT_S_DEFAULT 0 +#define PRINT_S_FLAGS 1 +#define PRINT_S_WIDTH 2 +#define PRINT_S_DOT 3 +#define PRINT_S_PRECISION 4 +#define PRINT_S_MOD 5 +#define PRINT_S_CONV 6 + +/* Format flags. */ +#define PRINT_F_MINUS (1 << 0) +#define PRINT_F_PLUS (1 << 1) +#define PRINT_F_SPACE (1 << 2) +#define PRINT_F_NUM (1 << 3) +#define PRINT_F_ZERO (1 << 4) +#define PRINT_F_QUOTE (1 << 5) +#define PRINT_F_UP (1 << 6) +#define PRINT_F_UNSIGNED (1 << 7) +#define PRINT_F_TYPE_G (1 << 8) +#define PRINT_F_TYPE_E (1 << 9) + +/* Conversion flags. */ +#define PRINT_C_CHAR 1 +#define PRINT_C_SHORT 2 +#define PRINT_C_LONG 3 +#define PRINT_C_LLONG 4 +#define PRINT_C_LDOUBLE 5 +#define PRINT_C_SIZE 6 +#define PRINT_C_PTRDIFF 7 +#define PRINT_C_INTMAX 8 + +#ifndef MAX +#define MAX(x, y) ((x >= y) ? x : y) +#endif /* !defined(MAX) */ +#ifndef CHARTOINT +#define CHARTOINT(ch) (ch - '0') +#endif /* !defined(CHARTOINT) */ +#ifndef ISDIGIT +#define ISDIGIT(ch) ('0' <= (unsigned char)ch && (unsigned char)ch <= '9') +#endif /* !defined(ISDIGIT) */ +#ifndef ISNAN +#define ISNAN(x) (x != x) +#endif /* !defined(ISNAN) */ +#ifndef ISINF +#define ISINF(x) ((x < -1 || x > 1) && x + x == x) +#endif /* !defined(ISINF) */ + +#ifdef OUTCHAR +#undef OUTCHAR +#endif /* defined(OUTCHAR) */ +#define OUTCHAR(str, len, size, ch) \ +do { \ + if (len + 1 < size) \ + str[len] = ch; \ + (len)++; \ +} while (/* CONSTCOND */ 0) + +static void fmtstr(char *, size_t *, size_t, const char *, int, int, int); +static void fmtint(char *, size_t *, size_t, INTMAX_T, int, int, int, int); +static void fmtflt(char *, size_t *, size_t, LDOUBLE, int, int, int, int *); +static void printsep(char *, size_t *, size_t); +static int getnumsep(int); +static int getexponent(LDOUBLE); +static int convert(UINTMAX_T, char *, size_t, int, int); +static UINTMAX_T cast(LDOUBLE); +static UINTMAX_T myround(LDOUBLE); +static LDOUBLE mypow10(int); + +//seems to be not needed anymore in this modern environment +//extern int errno; + +int +rpl_vsnprintf(char *str, size_t size, const char *format, va_list* args) +{ + LDOUBLE fvalue; + INTMAX_T value; + unsigned char cvalue; + const char *strvalue; + id idvalue; + INTMAX_T *intmaxptr; + PTRDIFF_T *ptrdiffptr; + SSIZE_T *sizeptr; + LLONG *llongptr; + long int *longptr; + int *intptr; + short int *shortptr; + signed char *charptr; + size_t len = 0; + int overflow = 0; + int base = 0; + int cflags = 0; + int flags = 0; + int width = 0; + int precision = -1; + int state = PRINT_S_DEFAULT; + char ch = *format++; + + /* + * C99 says: "If `n' is zero, nothing is written, and `s' may be a null + * pointer." (7.19.6.5, 2) We're forgiving and allow a NULL pointer + * even if a size larger than zero was specified. At least NetBSD's + * snprintf(3) does the same, as well as other versions of this file. + * (Though some of these versions will write to a non-NULL buffer even + * if a size of zero was specified, which violates the standard.) + */ + if (str == NULL && size != 0) + size = 0; + + while (ch != '\0') + switch (state) { + case PRINT_S_DEFAULT: + if (ch == '%') + state = PRINT_S_FLAGS; + else + OUTCHAR(str, len, size, ch); + ch = *format++; + break; + case PRINT_S_FLAGS: + switch (ch) { + case '-': + flags |= PRINT_F_MINUS; + ch = *format++; + break; + case '+': + flags |= PRINT_F_PLUS; + ch = *format++; + break; + case ' ': + flags |= PRINT_F_SPACE; + ch = *format++; + break; + case '#': + flags |= PRINT_F_NUM; + ch = *format++; + break; + case '0': + flags |= PRINT_F_ZERO; + ch = *format++; + break; + case '\'': /* SUSv2 flag (not in C99). */ + flags |= PRINT_F_QUOTE; + ch = *format++; + break; + default: + state = PRINT_S_WIDTH; + break; + } + break; + case PRINT_S_WIDTH: + if (ISDIGIT(ch)) { + ch = CHARTOINT(ch); + if (width > (INT_MAX - ch) / 10) { + overflow = 1; + goto out; + } + width = 10 * width + ch; + ch = *format++; + } else if (ch == '*') { + /* + * C99 says: "A negative field width argument is + * taken as a `-' flag followed by a positive + * field width." (7.19.6.1, 5) + */ + if ((width = va_arg(*args, int)) < 0) { + flags |= PRINT_F_MINUS; + width = -width; + } + ch = *format++; + state = PRINT_S_DOT; + } else + state = PRINT_S_DOT; + break; + case PRINT_S_DOT: + if (ch == '.') { + state = PRINT_S_PRECISION; + ch = *format++; + } else + state = PRINT_S_MOD; + break; + case PRINT_S_PRECISION: + if (precision == -1) + precision = 0; + if (ISDIGIT(ch)) { + ch = CHARTOINT(ch); + if (precision > (INT_MAX - ch) / 10) { + overflow = 1; + goto out; + } + precision = 10 * precision + ch; + ch = *format++; + } else if (ch == '*') { + /* + * C99 says: "A negative precision argument is + * taken as if the precision were omitted." + * (7.19.6.1, 5) + */ + if ((precision = va_arg(*args, int)) < 0) + precision = -1; + ch = *format++; + state = PRINT_S_MOD; + } else + state = PRINT_S_MOD; + break; + case PRINT_S_MOD: + switch (ch) { + case 'h': + ch = *format++; + if (ch == 'h') { /* It's a char. */ + ch = *format++; + cflags = PRINT_C_CHAR; + } else + cflags = PRINT_C_SHORT; + break; + case 'l': + ch = *format++; + if (ch == 'l') { /* It's a long long. */ + ch = *format++; + cflags = PRINT_C_LLONG; + } else + cflags = PRINT_C_LONG; + break; + case 'L': + cflags = PRINT_C_LDOUBLE; + ch = *format++; + break; + case 'j': + cflags = PRINT_C_INTMAX; + ch = *format++; + break; + case 't': + cflags = PRINT_C_PTRDIFF; + ch = *format++; + break; + case 'z': + cflags = PRINT_C_SIZE; + ch = *format++; + break; + } + state = PRINT_S_CONV; + break; + case PRINT_S_CONV: + switch (ch) { + case 'd': + /* FALLTHROUGH */ + case 'i': + switch (cflags) { + case PRINT_C_CHAR: + value = (signed char)va_arg(*args, int); + break; + case PRINT_C_SHORT: + value = (short int)va_arg(*args, int); + break; + case PRINT_C_LONG: + value = va_arg(*args, long int); + break; + case PRINT_C_LLONG: + value = va_arg(*args, LLONG); + break; + case PRINT_C_SIZE: + value = va_arg(*args, SSIZE_T); + break; + case PRINT_C_INTMAX: + value = va_arg(*args, INTMAX_T); + break; + case PRINT_C_PTRDIFF: + value = va_arg(*args, PTRDIFF_T); + break; + default: + value = va_arg(*args, int); + break; + } + fmtint(str, &len, size, value, 10, width, + precision, flags); + break; + case 'X': + flags |= PRINT_F_UP; + /* FALLTHROUGH */ + case 'x': + base = 16; + /* FALLTHROUGH */ + case 'o': + if (base == 0) + base = 8; + /* FALLTHROUGH */ + case 'u': + if (base == 0) + base = 10; + flags |= PRINT_F_UNSIGNED; + switch (cflags) { + case PRINT_C_CHAR: + value = (unsigned char)va_arg(*args, + unsigned int); + break; + case PRINT_C_SHORT: + value = (unsigned short int)va_arg(*args, + unsigned int); + break; + case PRINT_C_LONG: + value = va_arg(*args, unsigned long int); + break; + case PRINT_C_LLONG: + value = va_arg(*args, ULLONG); + break; + case PRINT_C_SIZE: + value = va_arg(*args, size_t); + break; + case PRINT_C_INTMAX: + value = va_arg(*args, UINTMAX_T); + break; + case PRINT_C_PTRDIFF: + value = va_arg(*args, UPTRDIFF_T); + break; + default: + value = va_arg(*args, unsigned int); + break; + } + fmtint(str, &len, size, value, base, width, + precision, flags); + break; + case 'A': + /* Not yet supported, we'll use "%F". */ + /* FALLTHROUGH */ + case 'E': + if (ch == 'E') + flags |= PRINT_F_TYPE_E; + /* FALLTHROUGH */ + case 'G': + if (ch == 'G') + flags |= PRINT_F_TYPE_G; + /* FALLTHROUGH */ + case 'F': + flags |= PRINT_F_UP; + /* FALLTHROUGH */ + case 'a': + /* Not yet supported, we'll use "%f". */ + /* FALLTHROUGH */ + case 'e': + if (ch == 'e') + flags |= PRINT_F_TYPE_E; + /* FALLTHROUGH */ + case 'g': + if (ch == 'g') + flags |= PRINT_F_TYPE_G; + /* FALLTHROUGH */ + case 'f': + if (cflags == PRINT_C_LDOUBLE) + fvalue = va_arg(*args, LDOUBLE); + else + fvalue = va_arg(*args, double); + fmtflt(str, &len, size, fvalue, width, + precision, flags, &overflow); + if (overflow) + goto out; + break; + case 'c': + cvalue = (unsigned char)va_arg(*args, int); + OUTCHAR(str, len, size, cvalue); + break; + case 's': + DDLogError(@"before s: %p", *args); + strvalue = va_arg(*args, char *); + DDLogError(@"after s: %p", *args); + DDLogError(@"value: %s", strvalue); + fmtstr(str, &len, size, strvalue, width, + precision, flags); + break; + case '@': + DDLogError(@"before @: %p", *args); + idvalue = va_arg(*args, id); + DDLogError(@"after @: %p", *args); + const char *cstr = [[NSString stringWithFormat:@"%@", idvalue] UTF8String]; + DDLogError(@"value: %s", cstr); + fmtstr(str, &len, size, cstr, width, + precision, flags); + break; + case 'p': + /* + * C99 says: "The value of the pointer is + * converted to a sequence of printing + * characters, in an implementation-defined + * manner." (C99: 7.19.6.1, 8) + */ + if ((strvalue = va_arg(*args, void *)) == NULL) + /* + * We use the glibc format. BSD prints + * "0x0", SysV "0". + */ + fmtstr(str, &len, size, "(nil)", width, + -1, flags); + else { + /* + * We use the BSD/glibc format. SysV + * omits the "0x" prefix (which we emit + * using the PRINT_F_NUM flag). + */ + flags |= PRINT_F_NUM; + flags |= PRINT_F_UNSIGNED; + fmtint(str, &len, size, + (UINTPTR_T)strvalue, 16, width, + precision, flags); + } + break; + case 'n': + switch (cflags) { + case PRINT_C_CHAR: + charptr = va_arg(*args, signed char *); + *charptr = (signed char)len; + break; + case PRINT_C_SHORT: + shortptr = va_arg(*args, short int *); + *shortptr = (short)len; + break; + case PRINT_C_LONG: + longptr = va_arg(*args, long int *); + *longptr = len; + break; + case PRINT_C_LLONG: + llongptr = va_arg(*args, LLONG *); + *llongptr = len; + break; + case PRINT_C_SIZE: + /* + * C99 says that with the "z" length + * modifier, "a following `n' conversion + * specifier applies to a pointer to a + * signed integer type corresponding to + * size_t argument." (7.19.6.1, 7) + */ + sizeptr = va_arg(*args, SSIZE_T *); + *sizeptr = len; + break; + case PRINT_C_INTMAX: + intmaxptr = va_arg(*args, INTMAX_T *); + *intmaxptr = len; + break; + case PRINT_C_PTRDIFF: + ptrdiffptr = va_arg(*args, PTRDIFF_T *); + *ptrdiffptr = len; + break; + default: + intptr = va_arg(*args, int *); + *intptr = (int)len; + break; + } + break; + case '%': /* Print a "%" character verbatim. */ + OUTCHAR(str, len, size, ch); + break; + default: /* Skip other characters. */ + break; + } + ch = *format++; + state = PRINT_S_DEFAULT; + base = cflags = flags = width = 0; + precision = -1; + break; + } +out: + if (len < size) + str[len] = '\0'; + else if (size > 0) + str[size - 1] = '\0'; + + if (overflow || len > INT_MAX) { + errno = EOVERFLOW; + return -1; + } + return (int)len; +} + +static void +fmtstr(char *str, size_t *len, size_t size, const char *value, int width, + int precision, int flags) +{ + int padlen, strln; /* Amount to pad. */ + int noprecision = (precision == -1); + + if (value == NULL) /* We're forgiving. */ + value = "(null)"; + + /* If a precision was specified, don't read the string past it. */ + for (strln = 0; (noprecision || strln < precision) && + value[strln] != '\0'; strln++) + continue; + + if ((padlen = width - strln) < 0) + padlen = 0; + if (flags & PRINT_F_MINUS) /* Left justify. */ + padlen = -padlen; + + while (padlen > 0) { /* Leading spaces. */ + OUTCHAR(str, *len, size, ' '); + padlen--; + } + while ((noprecision || precision-- > 0) && *value != '\0') { + OUTCHAR(str, *len, size, *value); + value++; + } + while (padlen < 0) { /* Trailing spaces. */ + OUTCHAR(str, *len, size, ' '); + padlen++; + } +} + +static void +fmtint(char *str, size_t *len, size_t size, INTMAX_T value, int base, int width, + int precision, int flags) +{ + UINTMAX_T uvalue; + char iconvert[MAX_CONVERT_LENGTH]; + char sign = 0; + char hexprefix = 0; + int spadlen = 0; /* Amount to space pad. */ + int zpadlen = 0; /* Amount to zero pad. */ + int pos; + int separators = (flags & PRINT_F_QUOTE); + int noprecision = (precision == -1); + + if (flags & PRINT_F_UNSIGNED) + uvalue = value; + else { + uvalue = (value >= 0) ? value : -value; + if (value < 0) + sign = '-'; + else if (flags & PRINT_F_PLUS) /* Do a sign. */ + sign = '+'; + else if (flags & PRINT_F_SPACE) + sign = ' '; + } + + pos = convert(uvalue, iconvert, sizeof(iconvert), base, + flags & PRINT_F_UP); + + if (flags & PRINT_F_NUM && uvalue != 0) { + /* + * C99 says: "The result is converted to an `alternative form'. + * For `o' conversion, it increases the precision, if and only + * if necessary, to force the first digit of the result to be a + * zero (if the value and precision are both 0, a single 0 is + * printed). For `x' (or `X') conversion, a nonzero result has + * `0x' (or `0X') prefixed to it." (7.19.6.1, 6) + */ + switch (base) { + case 8: + if (precision <= pos) + precision = pos + 1; + break; + case 16: + hexprefix = (flags & PRINT_F_UP) ? 'X' : 'x'; + break; + } + } + + if (separators) /* Get the number of group separators we'll print. */ + separators = getnumsep(pos); + + zpadlen = precision - pos - separators; + spadlen = width /* Minimum field width. */ + - separators /* Number of separators. */ + - MAX(precision, pos) /* Number of integer digits. */ + - ((sign != 0) ? 1 : 0) /* Will we print a sign? */ + - ((hexprefix != 0) ? 2 : 0); /* Will we print a prefix? */ + + if (zpadlen < 0) + zpadlen = 0; + if (spadlen < 0) + spadlen = 0; + + /* + * C99 says: "If the `0' and `-' flags both appear, the `0' flag is + * ignored. For `d', `i', `o', `u', `x', and `X' conversions, if a + * precision is specified, the `0' flag is ignored." (7.19.6.1, 6) + */ + if (flags & PRINT_F_MINUS) /* Left justify. */ + spadlen = -spadlen; + else if (flags & PRINT_F_ZERO && noprecision) { + zpadlen += spadlen; + spadlen = 0; + } + while (spadlen > 0) { /* Leading spaces. */ + OUTCHAR(str, *len, size, ' '); + spadlen--; + } + if (sign != 0) /* Sign. */ + OUTCHAR(str, *len, size, sign); + if (hexprefix != 0) { /* A "0x" or "0X" prefix. */ + OUTCHAR(str, *len, size, '0'); + OUTCHAR(str, *len, size, hexprefix); + } + while (zpadlen > 0) { /* Leading zeros. */ + OUTCHAR(str, *len, size, '0'); + zpadlen--; + } + while (pos > 0) { /* The actual digits. */ + pos--; + OUTCHAR(str, *len, size, iconvert[pos]); + if (separators > 0 && pos > 0 && pos % 3 == 0) + printsep(str, len, size); + } + while (spadlen < 0) { /* Trailing spaces. */ + OUTCHAR(str, *len, size, ' '); + spadlen++; + } +} + +static void +fmtflt(char *str, size_t *len, size_t size, LDOUBLE fvalue, int width, + int precision, int flags, int *overflow) +{ + LDOUBLE ufvalue; + UINTMAX_T intpart; + UINTMAX_T fracpart; + UINTMAX_T mask; + const char *infnan = NULL; + char iconvert[MAX_CONVERT_LENGTH]; + char fconvert[MAX_CONVERT_LENGTH]; + char econvert[5]; /* "e-300" (without nul-termination). */ + char esign = 0; + char sign = 0; + int leadfraczeros = 0; + int exponent = 0; + int emitpoint = 0; + int omitzeros = 0; + int omitcount = 0; + int padlen = 0; + int epos = 0; + int fpos = 0; + int ipos = 0; + int separators = (flags & PRINT_F_QUOTE); + int estyle = (flags & PRINT_F_TYPE_E); +#if HAVE_LOCALECONV && HAVE_LCONV_DECIMAL_POINT + struct lconv *lc = localeconv(); +#endif /* HAVE_LOCALECONV && HAVE_LCONV_DECIMAL_POINT */ + + /* + * AIX' man page says the default is 0, but C99 and at least Solaris' + * and NetBSD's man pages say the default is 6, and sprintf(3) on AIX + * defaults to 6. + */ + if (precision == -1) + precision = 6; + + if (fvalue < 0.0) + sign = '-'; + else if (flags & PRINT_F_PLUS) /* Do a sign. */ + sign = '+'; + else if (flags & PRINT_F_SPACE) + sign = ' '; + + if (ISNAN(fvalue)) + infnan = (flags & PRINT_F_UP) ? "NAN" : "nan"; + else if (ISINF(fvalue)) + infnan = (flags & PRINT_F_UP) ? "INF" : "inf"; + + if (infnan != NULL) { + if (sign != 0) + iconvert[ipos++] = sign; + while (*infnan != '\0') + iconvert[ipos++] = *infnan++; + fmtstr(str, len, size, iconvert, width, ipos, flags); + return; + } + + /* "%e" (or "%E") or "%g" (or "%G") conversion. */ + if (flags & PRINT_F_TYPE_E || flags & PRINT_F_TYPE_G) { + if (flags & PRINT_F_TYPE_G) { + /* + * If the precision is zero, it is treated as one (cf. + * C99: 7.19.6.1, 8). + */ + if (precision == 0) + precision = 1; + /* + * For "%g" (and "%G") conversions, the precision + * specifies the number of significant digits, which + * includes the digits in the integer part. The + * conversion will or will not be using "e-style" (like + * "%e" or "%E" conversions) depending on the precision + * and on the exponent. However, the exponent can be + * affected by rounding the converted value, so we'll + * leave this decision for later. Until then, we'll + * assume that we're going to do an "e-style" conversion + * (in order to get the exponent calculated). For + * "e-style", the precision must be decremented by one. + */ + precision--; + /* + * For "%g" (and "%G") conversions, trailing zeros are + * removed from the fractional portion of the result + * unless the "#" flag was specified. + */ + if (!(flags & PRINT_F_NUM)) + omitzeros = 1; + } + exponent = getexponent(fvalue); + estyle = 1; + } + +again: + /* + * Sorry, we only support 9, 19, or 38 digits (that is, the number of + * digits of the 32-bit, the 64-bit, or the 128-bit UINTMAX_MAX value + * minus one) past the decimal point due to our conversion method. + */ + switch (sizeof(UINTMAX_T)) { + case 16: + if (precision > 38) + precision = 38; + break; + case 8: + if (precision > 19) + precision = 19; + break; + default: + if (precision > 9) + precision = 9; + break; + } + + ufvalue = (fvalue >= 0.0) ? fvalue : -fvalue; + if (estyle) /* We want exactly one integer digit. */ + ufvalue /= mypow10(exponent); + + if ((intpart = cast(ufvalue)) == UINTMAX_MAX) { + *overflow = 1; + return; + } + + /* + * Factor of ten with the number of digits needed for the fractional + * part. For example, if the precision is 3, the mask will be 1000. + */ + mask = mypow10(precision); + /* + * We "cheat" by converting the fractional part to integer by + * multiplying by a factor of ten. + */ + if ((fracpart = myround(mask * (ufvalue - intpart))) >= mask) { + /* + * For example, ufvalue = 2.99962, intpart = 2, and mask = 1000 + * (because precision = 3). Now, myround(1000 * 0.99962) will + * return 1000. So, the integer part must be incremented by one + * and the fractional part must be set to zero. + */ + intpart++; + fracpart = 0; + if (estyle && intpart == 10) { + /* + * The value was rounded up to ten, but we only want one + * integer digit if using "e-style". So, the integer + * part must be set to one and the exponent must be + * incremented by one. + */ + intpart = 1; + exponent++; + } + } + + /* + * Now that we know the real exponent, we can check whether or not to + * use "e-style" for "%g" (and "%G") conversions. If we don't need + * "e-style", the precision must be adjusted and the integer and + * fractional parts must be recalculated from the original value. + * + * C99 says: "Let P equal the precision if nonzero, 6 if the precision + * is omitted, or 1 if the precision is zero. Then, if a conversion + * with style `E' would have an exponent of X: + * + * - if P > X >= -4, the conversion is with style `f' (or `F') and + * precision P - (X + 1). + * + * - otherwise, the conversion is with style `e' (or `E') and precision + * P - 1." (7.19.6.1, 8) + * + * Note that we had decremented the precision by one. + */ + if (flags & PRINT_F_TYPE_G && estyle && + precision + 1 > exponent && exponent >= -4) { + precision -= exponent; + estyle = 0; + goto again; + } + + if (estyle) { + if (exponent < 0) { + exponent = -exponent; + esign = '-'; + } else + esign = '+'; + + /* + * Convert the exponent. The sizeof(econvert) is 5. So, the + * econvert buffer can hold e.g. "e+999" and "e-999". We don't + * support an exponent which contains more than three digits. + * Therefore, the following stores are safe. + */ + epos = convert(exponent, econvert, 3, 10, 0); + /* + * C99 says: "The exponent always contains at least two digits, + * and only as many more digits as necessary to represent the + * exponent." (7.19.6.1, 8) + */ + if (epos == 1) + econvert[epos++] = '0'; + econvert[epos++] = esign; + econvert[epos++] = (flags & PRINT_F_UP) ? 'E' : 'e'; + } + + /* Convert the integer part and the fractional part. */ + ipos = convert(intpart, iconvert, sizeof(iconvert), 10, 0); + if (fracpart != 0) /* convert() would return 1 if fracpart == 0. */ + fpos = convert(fracpart, fconvert, sizeof(fconvert), 10, 0); + + leadfraczeros = precision - fpos; + + if (omitzeros) { + if (fpos > 0) /* Omit trailing fractional part zeros. */ + while (omitcount < fpos && fconvert[omitcount] == '0') + omitcount++; + else { /* The fractional part is zero, omit it completely. */ + omitcount = precision; + leadfraczeros = 0; + } + precision -= omitcount; + } + + /* + * Print a decimal point if either the fractional part is non-zero + * and/or the "#" flag was specified. + */ + if (precision > 0 || flags & PRINT_F_NUM) + emitpoint = 1; + if (separators) /* Get the number of group separators we'll print. */ + separators = getnumsep(ipos); + + padlen = width /* Minimum field width. */ + - ipos /* Number of integer digits. */ + - epos /* Number of exponent characters. */ + - precision /* Number of fractional digits. */ + - separators /* Number of group separators. */ + - (emitpoint ? 1 : 0) /* Will we print a decimal point? */ + - ((sign != 0) ? 1 : 0); /* Will we print a sign character? */ + + if (padlen < 0) + padlen = 0; + + /* + * C99 says: "If the `0' and `-' flags both appear, the `0' flag is + * ignored." (7.19.6.1, 6) + */ + if (flags & PRINT_F_MINUS) /* Left justifty. */ + padlen = -padlen; + else if (flags & PRINT_F_ZERO && padlen > 0) { + if (sign != 0) { /* Sign. */ + OUTCHAR(str, *len, size, sign); + sign = 0; + } + while (padlen > 0) { /* Leading zeros. */ + OUTCHAR(str, *len, size, '0'); + padlen--; + } + } + while (padlen > 0) { /* Leading spaces. */ + OUTCHAR(str, *len, size, ' '); + padlen--; + } + if (sign != 0) /* Sign. */ + OUTCHAR(str, *len, size, sign); + while (ipos > 0) { /* Integer part. */ + ipos--; + OUTCHAR(str, *len, size, iconvert[ipos]); + if (separators > 0 && ipos > 0 && ipos % 3 == 0) + printsep(str, len, size); + } + if (emitpoint) { /* Decimal point. */ +#if HAVE_LOCALECONV && HAVE_LCONV_DECIMAL_POINT + if (lc->decimal_point != NULL && *lc->decimal_point != '\0') + OUTCHAR(str, *len, size, *lc->decimal_point); + else /* We'll always print some decimal point character. */ +#endif /* HAVE_LOCALECONV && HAVE_LCONV_DECIMAL_POINT */ + OUTCHAR(str, *len, size, '.'); + } + while (leadfraczeros > 0) { /* Leading fractional part zeros. */ + OUTCHAR(str, *len, size, '0'); + leadfraczeros--; + } + while (fpos > omitcount) { /* The remaining fractional part. */ + fpos--; + OUTCHAR(str, *len, size, fconvert[fpos]); + } + while (epos > 0) { /* Exponent. */ + epos--; + OUTCHAR(str, *len, size, econvert[epos]); + } + while (padlen < 0) { /* Trailing spaces. */ + OUTCHAR(str, *len, size, ' '); + padlen++; + } +} + +static void +printsep(char *str, size_t *len, size_t size) +{ +#if HAVE_LOCALECONV && HAVE_LCONV_THOUSANDS_SEP + struct lconv *lc = localeconv(); + int i; + + if (lc->thousands_sep != NULL) + for (i = 0; lc->thousands_sep[i] != '\0'; i++) + OUTCHAR(str, *len, size, lc->thousands_sep[i]); + else +#endif /* HAVE_LOCALECONV && HAVE_LCONV_THOUSANDS_SEP */ + OUTCHAR(str, *len, size, ','); +} + +static int +getnumsep(int digits) +{ + int separators = (digits - ((digits % 3 == 0) ? 1 : 0)) / 3; +#if HAVE_LOCALECONV && HAVE_LCONV_THOUSANDS_SEP + int strln; + struct lconv *lc = localeconv(); + + /* We support an arbitrary separator length (including zero). */ + if (lc->thousands_sep != NULL) { + for (strln = 0; lc->thousands_sep[strln] != '\0'; strln++) + continue; + separators *= strln; + } +#endif /* HAVE_LOCALECONV && HAVE_LCONV_THOUSANDS_SEP */ + return separators; +} + +static int +getexponent(LDOUBLE value) +{ + LDOUBLE tmp = (value >= 0.0) ? value : -value; + int exponent = 0; + + /* + * We check for LDOUBLE_MAX_10_EXP >= exponent >= LDOUBLE_MIN_10_EXP in + * order to work around possible endless loops which could happen (at + * least) in the second loop (at least) if we're called with an infinite + * value. However, we checked for infinity before calling this function + * using our ISINF() macro, so this might be somewhat paranoid. + */ + while (tmp < 1.0 && tmp > 0.0 && --exponent >= LDOUBLE_MIN_10_EXP) + tmp *= 10; + while (tmp >= 10.0 && ++exponent <= LDOUBLE_MAX_10_EXP) + tmp /= 10; + + return exponent; +} + +static int +convert(UINTMAX_T value, char *buf, size_t size, int base, int caps) +{ + const char *digits = caps ? "0123456789ABCDEF" : "0123456789abcdef"; + size_t pos = 0; + + /* We return an unterminated buffer with the digits in reverse order. */ + do { + buf[pos++] = digits[value % base]; + value /= base; + } while (value != 0 && pos < size); + + return (int)pos; +} + +static UINTMAX_T +cast(LDOUBLE value) +{ + UINTMAX_T result; + + /* + * We check for ">=" and not for ">" because if UINTMAX_MAX cannot be + * represented exactly as an LDOUBLE value (but is less than LDBL_MAX), + * it may be increased to the nearest higher representable value for the + * comparison (cf. C99: 6.3.1.4, 2). It might then equal the LDOUBLE + * value although converting the latter to UINTMAX_T would overflow. + */ + if (value >= UINTMAX_MAX) + return UINTMAX_MAX; + + result = value; + /* + * At least on NetBSD/sparc64 3.0.2 and 4.99.30, casting long double to + * an integer type converts e.g. 1.9 to 2 instead of 1 (which violates + * the standard). Sigh. + */ + return (result <= value) ? result : result - 1; +} + +static UINTMAX_T +myround(LDOUBLE value) +{ + UINTMAX_T intpart = cast(value); + + return ((value - intpart) < 0.5) ? intpart : intpart + 1; +} + +static LDOUBLE +mypow10(int exponent) +{ + LDOUBLE result = 1; + + while (exponent > 0) { + result *= 10; + exponent--; + } + while (exponent < 0) { + result /= 10; + exponent++; + } + return result; +} +#endif /* !HAVE_VSNPRINTF */ + +#if !HAVE_VASPRINTF +#if NEED_MYMEMCPY +void * +mymemcpy(void *dst, void *src, size_t len) +{ + const char *from = src; + char *to = dst; + + /* No need for optimization, we use this only to replace va_copy(3). */ + while (len-- > 0) + *to++ = *from++; + return dst; +} +#endif /* NEED_MYMEMCPY */ + +int +rpl_vasprintf(char **ret, const char *format, va_list *ap) +{ + size_t size; + int len; + va_list aq; + + VA_COPY(aq, *ap); + DDLogError(@"before copy: aq=%p, ap=%p", aq, *ap); + len = vsnprintf(NULL, 0, format, &aq); + DDLogError(@"after copy: aq=%p, ap=%p", aq, *ap); + VA_END_COPY(aq); + if (len < 0 || (*ret = malloc(size = len + 1)) == NULL) + return -1; + DDLogError(@"before real: ap=%p", *ap); + len = vsnprintf(*ret, size, format, ap); + DDLogError(@"after real: ap=%p", *ap); + return len; +} +#endif /* !HAVE_VASPRINTF */ + +#if !HAVE_SNPRINTF +#if HAVE_STDARG_H +int +rpl_snprintf(char *str, size_t size, const char *format, ...) +#else +int +rpl_snprintf(va_alist) va_dcl +#endif /* HAVE_STDARG_H */ +{ +#if !HAVE_STDARG_H + char *str; + size_t size; + char *format; +#endif /* HAVE_STDARG_H */ + va_list ap; + int len; + + VA_START(ap, format); + VA_SHIFT(ap, str, char *); + VA_SHIFT(ap, size, size_t); + VA_SHIFT(ap, format, const char *); + len = vsnprintf(str, size, format, &ap); + va_end(ap); + return len; +} +#endif /* !HAVE_SNPRINTF */ + +#if !HAVE_ASPRINTF +#if HAVE_STDARG_H +int +rpl_asprintf(char **ret, const char *format, ...) +#else +int +rpl_asprintf(va_alist) va_dcl +#endif /* HAVE_STDARG_H */ +{ +#if !HAVE_STDARG_H + char **ret; + char *format; +#endif /* HAVE_STDARG_H */ + va_list ap; + int len; + + VA_START(ap, format); + VA_SHIFT(ap, ret, char **); + VA_SHIFT(ap, format, const char *); + len = vasprintf(ret, format, &ap); + va_end(ap); + return len; +} +#endif /* !HAVE_ASPRINTF */ +#else /* Dummy declaration to avoid empty translation unit warnings. */ +int main(int argc, char **argv); +#endif /* !HAVE_SNPRINTF || !HAVE_VSNPRINTF || !HAVE_ASPRINTF || [...] */ + +#if TEST_SNPRINTF +int +main(void) +{ + const char *float_fmt[] = { + /* "%E" and "%e" formats. */ +#if HAVE_LONG_LONG_INT && !OS_BSD && !OS_IRIX + "%.16e", + "%22.16e", + "%022.16e", + "%-22.16e", + "%#+'022.16e", +#endif /* HAVE_LONG_LONG_INT && !OS_BSD && !OS_IRIX */ + "foo|%#+0123.9E|bar", + "%-123.9e", + "%123.9e", + "%+23.9e", + "%+05.8e", + "%-05.8e", + "%05.8e", + "%+5.8e", + "%-5.8e", + "% 5.8e", + "%5.8e", + "%+4.9e", +#if !OS_LINUX /* glibc sometimes gets these wrong. */ + "%+#010.0e", + "%#10.1e", + "%10.5e", + "% 10.5e", + "%5.0e", + "%5.e", + "%#5.0e", + "%#5.e", + "%3.2e", + "%3.1e", + "%-1.5e", + "%1.5e", + "%01.3e", + "%1.e", + "%.1e", + "%#.0e", + "%+.0e", + "% .0e", + "%.0e", + "%#.e", + "%+.e", + "% .e", + "%.e", + "%4e", + "%e", + "%E", +#endif /* !OS_LINUX */ + /* "%F" and "%f" formats. */ +#if !OS_BSD && !OS_IRIX + "% '022f", + "%+'022f", + "%-'22f", + "%'22f", +#if HAVE_LONG_LONG_INT + "%.16f", + "%22.16f", + "%022.16f", + "%-22.16f", + "%#+'022.16f", +#endif /* HAVE_LONG_LONG_INT */ +#endif /* !OS_BSD && !OS_IRIX */ + "foo|%#+0123.9F|bar", + "%-123.9f", + "%123.9f", + "%+23.9f", + "%+#010.0f", + "%#10.1f", + "%10.5f", + "% 10.5f", + "%+05.8f", + "%-05.8f", + "%05.8f", + "%+5.8f", + "%-5.8f", + "% 5.8f", + "%5.8f", + "%5.0f", + "%5.f", + "%#5.0f", + "%#5.f", + "%+4.9f", + "%3.2f", + "%3.1f", + "%-1.5f", + "%1.5f", + "%01.3f", + "%1.f", + "%.1f", + "%#.0f", + "%+.0f", + "% .0f", + "%.0f", + "%#.f", + "%+.f", + "% .f", + "%.f", + "%4f", + "%f", + "%F", + /* "%G" and "%g" formats. */ +#if !OS_BSD && !OS_IRIX && !OS_LINUX + "% '022g", + "%+'022g", + "%-'22g", + "%'22g", +#if HAVE_LONG_LONG_INT + "%.16g", + "%22.16g", + "%022.16g", + "%-22.16g", + "%#+'022.16g", +#endif /* HAVE_LONG_LONG_INT */ +#endif /* !OS_BSD && !OS_IRIX && !OS_LINUX */ + "foo|%#+0123.9G|bar", + "%-123.9g", + "%123.9g", + "%+23.9g", + "%+05.8g", + "%-05.8g", + "%05.8g", + "%+5.8g", + "%-5.8g", + "% 5.8g", + "%5.8g", + "%+4.9g", +#if !OS_LINUX /* glibc sometimes gets these wrong. */ + "%+#010.0g", + "%#10.1g", + "%10.5g", + "% 10.5g", + "%5.0g", + "%5.g", + "%#5.0g", + "%#5.g", + "%3.2g", + "%3.1g", + "%-1.5g", + "%1.5g", + "%01.3g", + "%1.g", + "%.1g", + "%#.0g", + "%+.0g", + "% .0g", + "%.0g", + "%#.g", + "%+.g", + "% .g", + "%.g", + "%4g", + "%g", + "%G", +#endif /* !OS_LINUX */ + NULL + }; + double float_val[] = { + -4.136, + -134.52, + -5.04030201, + -3410.01234, + -999999.999999, + -913450.29876, + -913450.2, + -91345.2, + -9134.2, + -913.2, + -91.2, + -9.2, + -9.9, + 4.136, + 134.52, + 5.04030201, + 3410.01234, + 999999.999999, + 913450.29876, + 913450.2, + 91345.2, + 9134.2, + 913.2, + 91.2, + 9.2, + 9.9, + 9.96, + 9.996, + 9.9996, + 9.99996, + 9.999996, + 9.9999996, + 9.99999996, + 0.99999996, + 0.99999999, + 0.09999999, + 0.00999999, + 0.00099999, + 0.00009999, + 0.00000999, + 0.00000099, + 0.00000009, + 0.00000001, + 0.0000001, + 0.000001, + 0.00001, + 0.0001, + 0.001, + 0.01, + 0.1, + 1.0, + 1.5, + -1.5, + -1.0, + -0.1, +#if !OS_BSD /* BSD sometimes gets these wrong. */ +#ifdef INFINITY + INFINITY, + -INFINITY, +#endif /* defined(INFINITY) */ +#ifdef NAN + NAN, +#endif /* defined(NAN) */ +#endif /* !OS_BSD */ + 0 + }; + const char *long_fmt[] = { + "foo|%0123ld|bar", +#if !OS_IRIX + "% '0123ld", + "%+'0123ld", + "%-'123ld", + "%'123ld", +#endif /* !OS_IRiX */ + "%123.9ld", + "% 123.9ld", + "%+123.9ld", + "%-123.9ld", + "%0123ld", + "% 0123ld", + "%+0123ld", + "%-0123ld", + "%10.5ld", + "% 10.5ld", + "%+10.5ld", + "%-10.5ld", + "%010ld", + "% 010ld", + "%+010ld", + "%-010ld", + "%4.2ld", + "% 4.2ld", + "%+4.2ld", + "%-4.2ld", + "%04ld", + "% 04ld", + "%+04ld", + "%-04ld", + "%5.5ld", + "%+22.33ld", + "%01.3ld", + "%1.5ld", + "%-1.5ld", + "%44ld", + "%4ld", + "%4.0ld", + "%4.ld", + "%.44ld", + "%.4ld", + "%.0ld", + "%.ld", + "%ld", + NULL + }; + long int long_val[] = { +#ifdef LONG_MAX + LONG_MAX, +#endif /* LONG_MAX */ +#ifdef LONG_MIN + LONG_MIN, +#endif /* LONG_MIN */ + -91340, + 91340, + 341, + 134, + 0203, + -1, + 1, + 0 + }; + const char *ulong_fmt[] = { + /* "%u" formats. */ + "foo|%0123lu|bar", +#if !OS_IRIX + "% '0123lu", + "%+'0123lu", + "%-'123lu", + "%'123lu", +#endif /* !OS_IRiX */ + "%123.9lu", + "% 123.9lu", + "%+123.9lu", + "%-123.9lu", + "%0123lu", + "% 0123lu", + "%+0123lu", + "%-0123lu", + "%5.5lu", + "%+22.33lu", + "%01.3lu", + "%1.5lu", + "%-1.5lu", + "%44lu", + "%lu", + /* "%o" formats. */ + "foo|%#0123lo|bar", + "%#123.9lo", + "%# 123.9lo", + "%#+123.9lo", + "%#-123.9lo", + "%#0123lo", + "%# 0123lo", + "%#+0123lo", + "%#-0123lo", + "%#5.5lo", + "%#+22.33lo", + "%#01.3lo", + "%#1.5lo", + "%#-1.5lo", + "%#44lo", + "%#lo", + "%123.9lo", + "% 123.9lo", + "%+123.9lo", + "%-123.9lo", + "%0123lo", + "% 0123lo", + "%+0123lo", + "%-0123lo", + "%5.5lo", + "%+22.33lo", + "%01.3lo", + "%1.5lo", + "%-1.5lo", + "%44lo", + "%lo", + /* "%X" and "%x" formats. */ + "foo|%#0123lX|bar", + "%#123.9lx", + "%# 123.9lx", + "%#+123.9lx", + "%#-123.9lx", + "%#0123lx", + "%# 0123lx", + "%#+0123lx", + "%#-0123lx", + "%#5.5lx", + "%#+22.33lx", + "%#01.3lx", + "%#1.5lx", + "%#-1.5lx", + "%#44lx", + "%#lx", + "%#lX", + "%123.9lx", + "% 123.9lx", + "%+123.9lx", + "%-123.9lx", + "%0123lx", + "% 0123lx", + "%+0123lx", + "%-0123lx", + "%5.5lx", + "%+22.33lx", + "%01.3lx", + "%1.5lx", + "%-1.5lx", + "%44lx", + "%lx", + "%lX", + NULL + }; + unsigned long int ulong_val[] = { +#ifdef ULONG_MAX + ULONG_MAX, +#endif /* ULONG_MAX */ + 91340, + 341, + 134, + 0203, + 1, + 0 + }; + const char *llong_fmt[] = { + "foo|%0123lld|bar", + "%123.9lld", + "% 123.9lld", + "%+123.9lld", + "%-123.9lld", + "%0123lld", + "% 0123lld", + "%+0123lld", + "%-0123lld", + "%5.5lld", + "%+22.33lld", + "%01.3lld", + "%1.5lld", + "%-1.5lld", + "%44lld", + "%lld", + NULL + }; + LLONG llong_val[] = { +#ifdef LLONG_MAX + LLONG_MAX, +#endif /* LLONG_MAX */ +#ifdef LLONG_MIN + LLONG_MIN, +#endif /* LLONG_MIN */ + -91340, + 91340, + 341, + 134, + 0203, + -1, + 1, + 0 + }; + const char *string_fmt[] = { + "foo|%10.10s|bar", + "%-10.10s", + "%10.10s", + "%10.5s", + "%5.10s", + "%10.1s", + "%1.10s", + "%10.0s", + "%0.10s", + "%-42.5s", + "%2.s", + "%.10s", + "%.1s", + "%.0s", + "%.s", + "%4s", + "%s", + NULL + }; + const char *string_val[] = { + "Hello", + "Hello, world!", + "Sound check: One, two, three.", + "This string is a little longer than the other strings.", + "1", + "", + NULL + }; +#if !OS_SYSV /* SysV uses a different format than we do. */ + const char *pointer_fmt[] = { + "foo|%p|bar", + "%42p", + "%p", + NULL + }; + const char *pointer_val[] = { + *pointer_fmt, + *string_fmt, + *string_val, + NULL + }; +#endif /* !OS_SYSV */ + char buf1[1024], buf2[1024]; + double value, digits = 9.123456789012345678901234567890123456789; + int i, j, r1, r2, failed = 0, num = 0; + +/* + * Use -DTEST_NILS in order to also test the conversion of nil values. Might + * segfault on systems which don't support converting a NULL pointer with "%s" + * and lets some test cases fail against BSD and glibc due to bugs in their + * implementations. + */ +#ifndef TEST_NILS +#define TEST_NILS 0 +#elif TEST_NILS +#undef TEST_NILS +#define TEST_NILS 1 +#endif /* !defined(TEST_NILS) */ +#ifdef TEST +#undef TEST +#endif /* defined(TEST) */ +#define TEST(fmt, val) \ +do { \ + for (i = 0; fmt[i] != NULL; i++) \ + for (j = 0; j == 0 || val[j - TEST_NILS] != 0; j++) { \ + r1 = sprintf(buf1, fmt[i], val[j]); \ + r2 = snprintf(buf2, sizeof(buf2), fmt[i], val[j]); \ + if (strcmp(buf1, buf2) != 0 || r1 != r2) { \ + (void)printf("Results don't match, " \ + "format string: %s\n" \ + "\t sprintf(3): [%s] (%d)\n" \ + "\tsnprintf(3): [%s] (%d)\n", \ + fmt[i], buf1, r1, buf2, r2); \ + failed++; \ + } \ + num++; \ + } \ +} while (/* CONSTCOND */ 0) + +#if HAVE_LOCALE_H + (void)setlocale(LC_ALL, ""); +#endif /* HAVE_LOCALE_H */ + + (void)puts("Testing our snprintf(3) against your system's sprintf(3)."); + TEST(float_fmt, float_val); + TEST(long_fmt, long_val); + TEST(ulong_fmt, ulong_val); + TEST(llong_fmt, llong_val); + TEST(string_fmt, string_val); +#if !OS_SYSV /* SysV uses a different format than we do. */ + TEST(pointer_fmt, pointer_val); +#endif /* !OS_SYSV */ + (void)printf("Result: %d out of %d tests failed.\n", failed, num); + + (void)fputs("Checking how many digits we support: ", stdout); + for (i = 0; i < 100; i++) { + value = pow(10, i) * digits; + (void)sprintf(buf1, "%.1f", value); + (void)snprintf(buf2, sizeof(buf2), "%.1f", value); + if (strcmp(buf1, buf2) != 0) { + (void)printf("apparently %d.\n", i); + break; + } + } + return (failed == 0) ? 0 : 1; +} +#endif /* TEST_SNPRINTF */ + +/* vim: set joinspaces noexpandtab textwidth=80 cinoptions=(4,u0: */ diff --git a/Monal/Classes/xmpp.h b/Monal/Classes/xmpp.h new file mode 100644 index 0000000..46c3ee4 --- /dev/null +++ b/Monal/Classes/xmpp.h @@ -0,0 +1,256 @@ +// +// xmpp.h +// Monal +// +// Created by Anurodh Pokharel on 6/29/13. +// +// + +#import +#import "MLConstants.h" + +#import "HelperTools.h" + +#import "MLMessage.h" +#import "MLContact.h" + +#import "MLXMPPConnection.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM (NSInteger, xmppState) { + kStateLoggedOut = -1, + kStateDisconnected, // has connected once + kStateReconnecting, + kStateConnected, + kStateHasStream, + kStateLoggedIn, + kStateBinding, + kStateBound //is operating normally +}; + +typedef NS_ENUM (NSInteger, xmppRegistrationState) { + kStateRequestingForm = -1, + kStateSubmittingForm, + kStateFormResponseReceived, + kStateRegistered +}; + +typedef NS_ENUM (NSInteger, xmppPipeliningState) { + kPipelinedNothing = -1, + kPipelinedAuth, + kPipelinedResumeOrBind +}; + +FOUNDATION_EXPORT NSString* const kFileName; +FOUNDATION_EXPORT NSString* const kContentType; +FOUNDATION_EXPORT NSString* const kData; + +@class AnyPromise; +@class MLPubSub; +@class MLXMLNode; +@class XMPPDataForm; +@class XMPPStanza; +@class XMPPIQ; +@class XMPPMessage; +@class XMPPPresence; +@class MLOMEMO; +@class MLMessageProcessor; +@class MLMucProcessor; + +@class RTCSessionDescription; +@class RTCIceCandidate; + +typedef void (^xmppCompletion)(BOOL success, NSString* _Nullable message); +typedef void (^xmppDataCompletion)(NSData *captchaImage, NSDictionary *hiddenFields); +typedef void (^monal_iq_handler_t)(XMPPIQ* _Nullable); + +@interface xmpp : NSObject + +@property (nonatomic, readonly) BOOL idle; +@property (nonatomic, readonly) BOOL parseQueueFrozen; + +@property (nonatomic, strong) MLXMPPConnection* connectionProperties; + +//reg +@property (nonatomic, strong) NSString *regUser; +@property (nonatomic, strong) NSString *regPass; +@property (nonatomic, strong) NSString *regCode; +@property (nonatomic, strong) NSDictionary *regHidden; + +// state attributes +@property (nonatomic, strong) NSString* statusMessage; + +// DB info +@property (nonatomic, strong) NSNumber* accountID; + +@property (nonatomic, readonly) xmppState accountState; +@property (nonatomic, readonly) BOOL reconnectInProgress; +@property (nonatomic, readonly) BOOL isDoingFullReconnect; +@property (atomic, assign) BOOL hasSeenOmemoDeviceListAfterOwnDeviceid; + +// discovered properties +@property (nonatomic, strong) NSArray* discoveredServersList; +@property (nonatomic, strong) NSMutableArray* usableServersList; + +@property (nonatomic, strong) MLOMEMO* omemo; +@property (nonatomic, strong) MLPubSub* pubsub; +@property (nonatomic, strong) MLMucProcessor* mucProcessor; + +//calculated +@property (nonatomic, strong) NSDate* connectedTime; +@property (nonatomic, strong, readonly) MLXMLNode* capsIdentity; +@property (nonatomic, strong, readonly) NSSet* capsFeatures; +@property (nonatomic, strong, readonly) NSString* capsHash; +@property (nullable, nonatomic, strong, readonly) NSArray* supportedChannelBindingTypes; + +-(id) initWithServer:(nonnull MLXMPPServer*) server andIdentity:(nonnull MLXMPPIdentity*) identity andAccountID:(NSNumber*) accountID; + +-(void) freezeParseQueue; +-(void) unfreezeParseQueue; +-(void) freeze; +-(void) unfreeze; + +-(void) connect; +-(void) disconnect; +-(void) disconnect:(BOOL) explicitLogout; +-(void) reconnect; +-(void) reconnect:(double) wait; + +-(void) setPubSubNotificationsForNodes:(NSArray* _Nonnull) nodes persistState:(BOOL) persistState; + +-(void) accountStatusChanged; + +/** + send a message to a contact with xmpp id + */ +-(void) retractMessage:(MLMessage*) msg; +-(void) moderateMessage:(MLMessage*) msg withReason:(NSString*) reason; +-(void) sendMessage:(NSString*) message toContact:(MLContact*) contact isEncrypted:(BOOL) encrypt isUpload:(BOOL) isUpload andMessageId:(NSString*) messageId; +-(void) sendMessage:(NSString*) message toContact:(MLContact*) contact isEncrypted:(BOOL) encrypt isUpload:(BOOL) isUpload andMessageId:(NSString*) messageId withLMCId:(NSString* _Nullable) LMCId; +-(void) sendChatState:(BOOL) isTyping toContact:(nonnull MLContact*) contact; + +/** + crafts a ping and sends it + */ +-(void) sendPing:(double) timeout; + +/** + ack any stanzas we have + */ +-(void) sendLastAck; + + +/** + Adds the stanza to the output Queue + */ +-(void) send:(MLXMLNode*) stanza; +-(void) sendIq:(XMPPIQ*) iq withResponseHandler:(monal_iq_handler_t) resultHandler andErrorHandler:(monal_iq_handler_t) errorHandler; +-(void) sendIq:(XMPPIQ*) iq withHandler:(MLHandler* _Nullable) handler; + +/** + removes a contact from the roster + */ +-(void) removeFromRoster:(MLContact*) contact; + +/** + adds a new contact to the roster + */ +-(void) addToRoster:(MLContact*) contact withPreauthToken:(NSString* _Nullable) preauthToken; + +-(void) updateRosterItem:(MLContact*) contact withName:(NSString*) name; + +-(AnyPromise*) checkJidType:(NSString*) jid; + +/** + join a room on the conference server + */ +-(void) joinMuc:(NSString* _Nonnull) room; + +/** + leave specific room. the nick name is the name used in the room. + it is arbitrary and it may not match any other hame. + */ +-(void) leaveMuc:(NSString* _Nonnull) room; + +/* + notifies the server client is in foreground + */ +-(void) setClientActive; + +/* + notifies the server client is in foreground + */ +-(void) setClientInactive; + +/* + HTTP upload +*/ + -(void) requestHTTPSlotWithParams:(NSDictionary *)params andCompletion:(void(^)(NSString *url, NSError *error)) completion; + + +-(void) setMAMQueryMostRecentForContact:(MLContact*) contact before:(NSString* _Nullable) uid withCompletion:(void (^)(NSArray* _Nullable, NSString* _Nullable error)) completion; +-(void) setMAMPrefs:(NSString*) preference; +-(void) getMAMPrefs; + +/** + enable APNS push with provided tokens + */ +-(void) enablePush; +-(void) disablePush; + +-(void) mamFinishedFor:(NSString*) archiveJid; + +/** + query a user's software version + */ +-(void) getEntitySoftWareVersion:(NSString*) user; + +/** + XEP-0191 blocking + */ +-(void) setBlocked:(BOOL) blocked forJid:(NSString*) jid; +-(void) fetchBlocklist; +-(void) updateLocalBlocklistCache:(NSSet*) blockedJids; + + +#pragma mark - account management + +-(void) changePassword:(NSString*) newPass withCompletion:(xmppCompletion _Nullable) completion; + +-(void) requestRegFormWithToken:(NSString* _Nullable) token andCompletion:(xmppDataCompletion) completion andErrorCompletion:(xmppCompletion) errorCompletion; +-(void) registerUser:(NSString*) username withPassword:(NSString*) password captcha:(NSString* _Nullable) captcha andHiddenFields:(NSDictionary* _Nullable) hiddenFields withCompletion:(xmppCompletion _Nullable) completion; + +-(void) publishRosterName:(NSString* _Nullable) rosterName; + +#pragma mark - internal stuff for processors + +-(BOOL) shouldTriggerSyncErrorForImportantUnackedOutgoingStanzas; +-(void) addMessageToMamPageArray:(NSDictionary*) messageDictionary; +-(NSMutableArray*) getOrderedMamPageFor:(NSString*) mamQueryId; +-(void) bindResource:(NSString*) resource; +-(void) initSession; +-(void) sendDisplayMarkerForMessages:(NSArray*) unread; +-(void) publishAvatar:(UIImage*) image; +-(void) publishStatusMessage:(NSString*) message; +-(void) delayIncomingMessageStanzasForArchiveJid:(NSString*) archiveJid; + ++(NSMutableDictionary*) invalidateState:(NSDictionary*) dic; +-(void) updateIqHandlerTimeouts; + +-(void) addReconnectionHandler:(MLHandler*) handler; + +-(void) removeFromServerWithCompletion:(void (^)(NSString* _Nullable error)) completion; + +-(void) queryExternalServicesOn:(NSString*) jid; +-(void) queryExternalServiceCredentialsFor:(NSDictionary*) service completion:(monal_id_block_t) completion; + +-(void) createInvitationWithCompletion:(monal_id_block_t) completion; + +-(void) markCapsQueryCompleteFor:(NSString*) ver; + +-(void) updateMdsData:(NSDictionary*) mdsData; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/xmpp.m b/Monal/Classes/xmpp.m new file mode 100644 index 0000000..aabe76c --- /dev/null +++ b/Monal/Classes/xmpp.m @@ -0,0 +1,5619 @@ +// +// xmpp.m +// Monal +// +// Created by Anurodh Pokharel on 6/29/13. +// +// + +#include + +#import +#import +#import + +#import "xmpp.h" +#import "MLDNSLookup.h" +#import "MLSignalStore.h" +#import "MLPubSub.h" +#import "MLOMEMO.h" + +#import "MLStream.h" +#import "MLPipe.h" +#import "MLProcessLock.h" +#import "DataLayer.h" +#import "HelperTools.h" +#import "MLXMPPManager.h" +#import "MLNotificationQueue.h" +#import "SCRAM.h" +#import "MLImageManager.h" + +//XMPP objects +#import "MLBasePaser.h" +#import "MLXMLNode.h" +#import "XMPPStanza.h" +#import "XMPPDataForm.h" +#import "XMPPIQ.h" +#import "XMPPPresence.h" +#import "XMPPMessage.h" + +//processors +#import "MLMessageProcessor.h" +#import "MLIQProcessor.h" +#import "MLPubSubProcessor.h" +#import "MLMucProcessor.h" + +#import "MLHTTPRequest.h" +#import "AESGcm.h" + +@import AVFoundation; + +#define STATE_VERSION 18 +#define CONNECT_TIMEOUT 7.0 +#define IQ_TIMEOUT 60.0 +NSString* const kQueueID = @"queueID"; +NSString* const kStanza = @"stanza"; + + +@interface MLPubSub () +-(id) initWithAccount:(xmpp*) account; +-(NSDictionary*) getInternalData; +-(void) setInternalData:(NSDictionary*) data; +-(void) invalidateQueue; +-(void) handleHeadlineMessage:(XMPPMessage*) messageNode; +@end + +@interface MLMucProcessor () +-(id) initWithAccount:(xmpp*) account; +-(NSDictionary*) getInternalState; +-(void) setInternalState:(NSDictionary*) state; +@end + + +@interface xmpp() +{ + //network (stream) related stuff + MLPipe* _iPipe; + NSOutputStream* _oStream; + NSMutableArray* _outputQueue; + // buffer for stanzas we can not (completely) write to the tcp socket + uint8_t* _outputBuffer; + size_t _outputBufferByteCount; + BOOL _streamHasSpace; + + //parser and queue related stuff + NSXMLParser* _xmlParser; + MLBasePaser* _baseParserDelegate; + NSOperationQueue* _parseQueue; + NSOperationQueue* _receiveQueue; + NSOperationQueue* _sendQueue; + + //does not reset at disconnect + BOOL _loggedInOnce; + BOOL _isCSIActive; + NSDate* _lastInteractionDate; + + //internal handlers and flags + MLDelayableTimer* _loginTimer; + MLDelayableTimer* _pingTimer; + MLDelayableTimer* _reconnectTimer; + NSMutableArray* _timersToCancelOnDisconnect; + NSMutableArray* _smacksAckHandler; + NSMutableDictionary* _iqHandlers; + NSMutableArray* _reconnectionHandlers; + NSMutableSet* _runningCapsQueries; + NSMutableDictionary* _runningMamQueries; + BOOL _SRVDiscoveryDone; + BOOL _startTLSComplete; + BOOL _catchupDone; + double _reconnectBackoffTime; + BOOL _reconnectInProgress; + BOOL _disconnectInProgres; + NSObject* _stateLockObject; //only used for @synchronized() blocks + BOOL _lastIdleState; + NSMutableDictionary* _mamPageArrays; + NSString* _internalID; + NSString* _logtag; + NSMutableDictionary* _inCatchup; + NSMutableDictionary* _mdsData; + + //registration related stuff + BOOL _registration; + BOOL _registrationSubmission; + NSString* _registrationToken; + xmppDataCompletion _regFormCompletion; + xmppCompletion _regFormErrorCompletion; + xmppCompletion _regFormSubmitCompletion; + + //pipelining related stuff + MLXMLNode* _cachedStreamFeaturesBeforeAuth; + MLXMLNode* _cachedStreamFeaturesAfterAuth; + xmppPipeliningState _pipeliningState; + + //scram related stuff + SCRAM* _scramHandler; + NSSet* _supportedSaslMechanisms; + NSSet* _supportedChannelBindings; + monal_void_block_t _blockToCallOnTCPOpen; + NSString* _upgradeTask; + + //catchup statistics + uint32_t _catchupStanzaCounter; + NSDate* _catchupStartTime; +} + +@property (nonatomic, assign) BOOL smacksRequestInFlight; + +@property (nonatomic, assign) BOOL resuming; +@property (atomic, strong) NSString* streamID; +@property (nonatomic, assign) BOOL isDoingFullReconnect; + +/** + h to go out in r stanza + */ +@property (nonatomic, strong) NSNumber* lastHandledInboundStanza; + +/** + h from a stanza + */ +@property (nonatomic, strong) NSNumber* lastHandledOutboundStanza; + +/** + internal counter that should match lastHandledOutboundStanza + */ +@property (nonatomic, strong) NSNumber* lastOutboundStanza; + +/** + Array of NSDictionary with stanzas that have not been acked. + NSDictionary {queueID, stanza} + */ +@property (nonatomic, strong) NSMutableArray* unAckedStanzas; + +@end + + + +@implementation xmpp + +-(id) initWithServer:(nonnull MLXMPPServer*) server andIdentity:(nonnull MLXMPPIdentity*) identity andAccountID:(NSNumber*) accountID +{ + //initialize ivars depending on provided arguments + self = [super init]; + u_int32_t i = arc4random(); + _internalID = [HelperTools hexadecimalString:[NSData dataWithBytes: &i length: sizeof(i)]]; + _logtag = [NSString stringWithFormat:@"[%@:%@]", accountID, _internalID]; + DDLogVerbose(@"Creating account %@ with id %@", accountID, _internalID); + self.accountID = accountID; + self.connectionProperties = [[MLXMPPConnection alloc] initWithServer:server andIdentity:identity]; + + //setup all other ivars + [self setupObjects]; + + // don't init omemo on ibr account creation + if(accountID.intValue >= 0) + self.omemo = [[MLOMEMO alloc] initWithAccount:self]; + + //read persisted state to make sure we never operate stateless + //WARNING: pubsub node registrations should only be made *after* the first readState call + [self readState]; + + //register devicelist and notification handler (MUST be done *after* reading state) + //[self readState] needs a valid self.omemo to load omemo state, + //but [self.omemo activate] needs a valid pubsub node registration loaded by [self readState] + //--> split "init" method into "init" and "activate" methods + if(self.omemo) + [self.omemo activate]; + + //we want to get automatic avatar updates (XEP-0084) + [self.pubsub registerForNode:@"urn:xmpp:avatar:metadata" withHandler:$newHandler(MLPubSubProcessor, avatarHandler)]; + + //we want to get automatic roster name updates (XEP-0172) + [self.pubsub registerForNode:@"http://jabber.org/protocol/nick" withHandler:$newHandler(MLPubSubProcessor, rosterNameHandler)]; + + //we want to get automatic bookmark updates (XEP-0048) + //this will only be used/handled, if the account disco feature urn:xmpp:bookmarks:1#compat-pep is not set by the server and ignored otherwise + //(it will be automatically headline-pushed nevertheless --> TODO: remove this once all modern servers support XEP-0402 compat) + [self.pubsub registerForNode:@"storage:bookmarks" withHandler:$newHandler(MLPubSubProcessor, bookmarksHandler)]; + + //we now support the modern bookmarks protocol (XEP-0402) + [self.pubsub registerForNode:@"urn:xmpp:bookmarks:1" withHandler:$newHandler(MLPubSubProcessor, bookmarks2Handler)]; + + //we support mds + [self.pubsub registerForNode:@"urn:xmpp:mds:displayed:0" withHandler:$newHandler(MLPubSubProcessor, mdsHandler)]; + + return self; +} + +-(void) setupObjects +{ + //initialize _capsIdentity, _capsFeatures and _capsHash + _capsIdentity = [[MLXMLNode alloc] initWithElement:@"identity" withAttributes:@{ + @"category": @"client", + @"type": @"phone", + @"name": [NSString stringWithFormat:@"Monal %@", [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]] + } andChildren:@[] andData:nil]; + _capsFeatures = [HelperTools getOwnFeatureSet]; + NSString* client = [NSString stringWithFormat:@"%@/%@//%@", [_capsIdentity findFirst:@"/@category"], [_capsIdentity findFirst:@"/@type"], [_capsIdentity findFirst:@"/@name"]]; + [self setCapsHash:[HelperTools getEntityCapsHashForIdentities:@[client] andFeatures:_capsFeatures andForms:@[]]]; + + //init pubsub as early as possible to allow other classes or other parts of this file to register pubsub nodes they are interested in + self.pubsub = [[MLPubSub alloc] initWithAccount:self]; + + //init muc processor + self.mucProcessor = [[MLMucProcessor alloc] initWithAccount:self]; + + _stateLockObject = [NSObject new]; + [self initSM3]; + self.isDoingFullReconnect = YES; + + _accountState = kStateLoggedOut; + _registration = NO; + _registrationSubmission = NO; + _startTLSComplete = NO; + _catchupDone = NO; + _reconnectInProgress = NO; + _disconnectInProgres = NO; + _lastIdleState = NO; + _outputQueue = [NSMutableArray new]; + _iqHandlers = [NSMutableDictionary new]; + _reconnectionHandlers = [NSMutableArray new]; + _mamPageArrays = [NSMutableDictionary new]; + _runningCapsQueries = [NSMutableSet new]; + _runningMamQueries = [NSMutableDictionary new]; + _inCatchup = [NSMutableDictionary new]; + _mdsData = [NSMutableDictionary new]; + _pipeliningState = kPipelinedNothing; + _cachedStreamFeaturesBeforeAuth = nil; + _cachedStreamFeaturesAfterAuth = nil; + _timersToCancelOnDisconnect = [NSMutableArray new]; + + _SRVDiscoveryDone = NO; + _discoveredServersList = [NSMutableArray new]; + if(!_usableServersList) + _usableServersList = [NSMutableArray new]; + _reconnectBackoffTime = 0; + + _parseQueue = [NSOperationQueue new]; + _parseQueue.name = [NSString stringWithFormat:@"parseQueue[%@:%@]", self.accountID, _internalID]; + _parseQueue.qualityOfService = NSQualityOfServiceUtility; + _parseQueue.maxConcurrentOperationCount = 1; + [_parseQueue addObserver:self forKeyPath:@"operationCount" options:NSKeyValueObservingOptionNew context:nil]; + + _receiveQueue = [NSOperationQueue new]; + _receiveQueue.name = [NSString stringWithFormat:@"receiveQueue[%@:%@]", self.accountID, _internalID]; + _receiveQueue.qualityOfService = NSQualityOfServiceUserInitiated; + _receiveQueue.maxConcurrentOperationCount = 1; + [_receiveQueue addObserver:self forKeyPath:@"operationCount" options:NSKeyValueObservingOptionNew context:nil]; + + _sendQueue = [NSOperationQueue new]; + _sendQueue.name = [NSString stringWithFormat:@"sendQueue[%@:%@]", self.accountID, _internalID]; + _sendQueue.qualityOfService = NSQualityOfServiceUserInitiated; + _sendQueue.maxConcurrentOperationCount = 1; + [_sendQueue addObserver:self forKeyPath:@"operationCount" options:NSKeyValueObservingOptionNew context:nil]; + if(_outputBuffer) + free(_outputBuffer); + _outputBuffer = nil; + _outputBufferByteCount = 0; + + _isCSIActive = YES; //default value is yes if no csi state was set yet + if([HelperTools isAppExtension]) + { + DDLogVerbose(@"Called from extension: CSI inactive"); + _isCSIActive = NO; //we are always inactive when called from an extension + } + else if([HelperTools isInBackground]) + { + DDLogVerbose(@"Called in background: CSI inactive"); + _isCSIActive = NO; + } + _lastInteractionDate = [NSDate date]; //better default than 1970 + + self.statusMessage = @""; +} + +-(void) dealloc +{ + DDLogInfo(@"Deallocating account %@ object %@", self.accountID, self); + if(_outputBuffer) + free(_outputBuffer); + _outputBuffer = nil; + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [_parseQueue removeObserver:self forKeyPath:@"operationCount"]; + [_receiveQueue removeObserver:self forKeyPath:@"operationCount"]; + [_sendQueue removeObserver:self forKeyPath:@"operationCount"]; + [_parseQueue cancelAllOperations]; + [_receiveQueue cancelAllOperations]; + [_sendQueue cancelAllOperations]; + DDLogInfo(@"Done deallocating account %@ object %@", self.accountID, self); +} + +-(void) setCapsHash:(NSString* _Nonnull) hash +{ + //check if the hash has changed and broadcast a new presence after updating the property + if(![hash isEqualToString:_capsHash]) + { + DDLogInfo(@"New caps hash: %@", hash); + _capsHash = hash; + //broadcast new version hash (will be ignored if we are not bound) + if(_accountState >= kStateBound) + [self sendPresence]; + } +} + +-(void) setPubSubNotificationsForNodes:(NSArray* _Nonnull) nodes persistState:(BOOL) persistState +{ + NSString* client = [NSString stringWithFormat:@"%@/%@//%@", [_capsIdentity findFirst:@"/@category"], [_capsIdentity findFirst:@"/@type"], [_capsIdentity findFirst:@"/@name"]]; + NSMutableSet* featuresSet = [[NSMutableSet alloc] initWithSet:[HelperTools getOwnFeatureSet]]; + for(NSString* pubsubNode in nodes) + { + DDLogInfo(@"Added additional caps feature for pubsub node: %@", pubsubNode); + [featuresSet addObject:[NSString stringWithFormat:@"%@+notify", pubsubNode]]; + } + _capsFeatures = featuresSet; + [self setCapsHash:[HelperTools getEntityCapsHashForIdentities:@[client] andFeatures:_capsFeatures andForms:@[]]]; + + //persist this new state if the pubsub implementation tells us to + if(persistState) + [self persistState]; +} + +-(void) postError:(NSString*) message withIsSevere:(BOOL) isSevere +{ + // Do not show "Connection refused" message and other errors occuring before we are in kStateHasStream, if we still have more SRV records to try + if([_usableServersList count] == 0 || _accountState >= kStateHasStream) + [HelperTools postError:message withNode:nil andAccount:self andIsSevere:isSevere]; +} + +-(void) invalidXMLError +{ + DDLogError(@"Server returned invalid xml!"); + DDLogDebug(@"Setting _pipeliningState to kPipelinedNothing and clearing _cachedStreamFeaturesBeforeAuth and _cachedStreamFeaturesAfterAuth..."); + _pipeliningState = kPipelinedNothing; + _cachedStreamFeaturesBeforeAuth = nil; + _cachedStreamFeaturesAfterAuth = nil; + [self postError:NSLocalizedString(@"Server returned invalid xml!", @"") withIsSevere:NO]; + [self reconnect]; + return; +} + +-(void) dispatchOnReceiveQueue: (void (^)(void)) operation +{ + [self dispatchOnReceiveQueue:operation async:NO]; +} + +-(void) dispatchAsyncOnReceiveQueue: (void (^)(void)) operation +{ + [self dispatchOnReceiveQueue:operation async:YES]; +} + +-(void) dispatchOnReceiveQueue: (void (^)(void)) operation async:(BOOL) async +{ + if([NSOperationQueue currentQueue]!=_receiveQueue) + { + DDLogVerbose(@"DISPATCHING %@ OPERATION ON RECEIVE QUEUE %@: %lu", async ? @"ASYNC" : @"*sync*", [_receiveQueue name], [_receiveQueue operationCount]); + [_receiveQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:operation]] waitUntilFinished:!async]; + } + else + operation(); +} + +-(void) accountStatusChanged +{ + // Send notification that our account state has changed + [[MLNotificationQueue currentQueue] postNotificationName:kMonalAccountStatusChanged object:self userInfo:@{ + kAccountID: self.accountID, + kAccountState: [[NSNumber alloc] initWithInt:(int)self.accountState], + }]; +} + +-(void) observeValueForKeyPath:(NSString*) keyPath ofObject:(id) object change:(NSDictionary *) change context:(void*) context +{ + //check for idle state every time the number of operations in _sendQueue, _parseQueue or _receiveQueue changes + if((object == _sendQueue || object == _receiveQueue || object == _parseQueue) && [@"operationCount" isEqual: keyPath]) + { + //check idle state if this queue is empty and if so, publish kMonalIdle notification + //only do the (more heavy but complete) idle check if we reache zero operations in the observed queue + //if the idle check returnes a state change from non-idle to idle, we dispatch the idle notification on the receive queue + //to account for races between the second idle check done in the idle notification handler and calls to disconnect + //issued in response to this idle notification + //NOTE: yes, doing the check for operationCount of all queues (inside [self idle]) from an arbitrary thread is not race free. + //with such disconnects, but: we only want to track the send queue on a best effort basis (because network sends are best effort, too) + //to some extent we want to make sure every stanza was physically sent out to the network before our app gets frozen by ios, + //but we don't need to make this completely race free (network "races" can occur far more often than send queue races). + //in a race the smacks unacked stanzas array will contain the not yet sent stanzas --> we won't loose stanzas when racing the send queue + //with [self disconnect] through an idle check + //races on the idleness of the parse queue are even less severe and can be ignored entirely (they just have the effect as if a parsed + //stanza would not have been received by monal in the first place, because disconnect disrupted the network connection just before the + //stanza came in). + //NOTE: we only want to do an idle check if we are not in the middle of a disconnect call because this can race when the _bgTask is expiring + //and cancel the new _bgFetch because we are now idle (the dispatchAsyncOnReceiveQueue: will add a new task to the receive queue when + //the send queue gets cleaned up and this task will run as soon as the disconnect is done and interfere with the configuration of the + //_bgFetch and the syncError push notification both created on the main thread + if(![object operationCount] && !_disconnectInProgres) + { + //make sure we do a real async dispatch not using the shortcut in dispatchAsyncOnReceiveQueue: because that could cause races + //BACKGROUND EXPLANATION: apple calls observeValueForKeyPath: after completing the operation on the NSOperationQueue from the same thread, + //the operation was executed in. But because the operation is already finished by the time this value observer is called, + //the next operation could already be executing in another thread, while this observer does the async dispatch to the receive queue. + //The async dispatch implemented in dispatchAsyncOnReceiveQueue: tests, if we are already inside the receive queue and, if so, + //executes the operation directly, without queueing it to the receive queue. + //This check is true, even if we are in the value observer (even though this code technically does not + //run inside an operation on the receive queue, the check for the runnin queue in our async dispatch function still detects (erroneously) + //that we still are "inside" the receive queue and calls the block directly rather than enqueueing a new operation on the receive queue. + //That means we now have *2* threads executing code in the receive queue despite the queue being a serial queue + //--> deadlocks or malicious concurrent access can happen + //example taken from the wild (steve): the idle check in the *old* receive queue mach thread calls disconnect and tries to write + //the final account state to the database while the next stanza is being processed in the *real* receive queue mach thread holding a + //database transaction open. Both threads race against each other and a deadlock occurs that finally results in a MLSQLite exception + //thrown because the sqlite3_busy_timeout triggers after 8 seconds. + + BOOL lastState = self->_lastIdleState; + //only send out idle state notifications if the receive queue is not currently suspended (e.g. account frozen) + if(!_receiveQueue.suspended) + { + BOOL idle = self.idle; + //only send out idle notifications if we changed from non-idle to idle state + if(idle && !lastState) + { + DDLogVerbose(@"Adding idle state notification to receive queue..."); + [_receiveQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + //don't queue this notification because it should be handled INLINE inside the receive queue + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalIdle object:self]; + }]] waitUntilFinished:NO]; + } + //only send out not-idle notifications if we changed from idle to non-idle state + if(!idle && lastState) + { + DDLogVerbose(@"Adding non-idle state notification to receive queue..."); + [_receiveQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + //don't queue this notification because it should be handled INLINE inside the receive queue + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalNotIdle object:self]; + }]] waitUntilFinished:NO]; + } + } + } + } +} + +-(BOOL) idle +{ + BOOL retval = NO; + //we are idle when we are not connected (and not trying to) + //or: the catchup is done, no unacked stanzas are left in the smacks queue and receive and send queues are empty (no pending operations) + unsigned long unackedCount = 0; + @synchronized(_stateLockObject) { + unackedCount = (unsigned long)[self.unAckedStanzas count]; + } + if( + ( + //test if this account was permanently logged out but still has stanzas pending (this can happen if we have no connectivity for example) + //--> we are not idle in this case because we still have pending outgoing stanzas + _accountState Idle check:\n" + "\t_accountState < kStateReconnecting = %@\n" + "\t_reconnectInProgress = %@\n" + "\t_catchupDone = %@\n" + "\t_pingTimer = %@\n" + "\t[self.unAckedStanzas count] = %lu\n" + "\t[_parseQueue operationCount] = %lu\n" + //"\t[_receiveQueue operationCount] = %lu\n" + "\t[_sendQueue operationCount] = %lu\n" + "\t[[_inCatchup count] = %lu\n\t--> %@" + ), + self.accountID, + bool2str(_accountState < kStateReconnecting), + bool2str(_reconnectInProgress), + bool2str(_catchupDone), + _pingTimer == nil ? @"none" : @"running timer", + unackedCount, + (unsigned long)[_parseQueue operationCount], + //(unsigned long)[_receiveQueue operationCount], + (unsigned long)[_sendQueue operationCount], + (unsigned long)[_inCatchup count], + retval ? @"idle" : @"NOT IDLE" + ); + return retval; +} + +-(void) cleanupSendQueue +{ + DDLogVerbose(@"Cleaning up sendQueue"); + [_sendQueue cancelAllOperations]; + [self unfreezeSendQueue]; //make sure the queue is operational again before dispatching to it + [_sendQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + DDLogVerbose(@"Cleaning up sendQueue [internal]"); + [self->_sendQueue cancelAllOperations]; + self->_outputQueue = [NSMutableArray new]; + if(self->_outputBuffer) + free(self->_outputBuffer); + self->_outputBuffer = nil; + self->_outputBufferByteCount = 0; + self->_streamHasSpace = NO; + DDLogVerbose(@"Cleanup of sendQueue finished [internal]"); + }]] waitUntilFinished:YES]; + DDLogVerbose(@"Cleanup of sendQueue finished"); +} + +-(void) createStreams +{ + DDLogInfo(@"stream creating to server: %@ port: %@ directTLS: %@", self.connectionProperties.server.connectServer, self.connectionProperties.server.connectPort, bool2str(self.connectionProperties.server.isDirectTLS)); + + NSInputStream* localIStream; + NSOutputStream* localOStream; + + if(self.connectionProperties.server.isDirectTLS == YES) + { + DDLogInfo(@"creating directTLS streams"); + [MLStream connectWithSNIDomain:self.connectionProperties.identity.domain connectHost:self.connectionProperties.server.connectServer connectPort:self.connectionProperties.server.connectPort tls:YES inputStream:&localIStream outputStream:&localOStream logtag:self->_logtag]; + } + else + { + DDLogInfo(@"creating plaintext streams"); + [MLStream connectWithSNIDomain:self.connectionProperties.identity.domain connectHost:self.connectionProperties.server.connectServer connectPort:self.connectionProperties.server.connectPort tls:NO inputStream:&localIStream outputStream:&localOStream logtag:self->_logtag]; + } + + if(localOStream) + _oStream = localOStream; + + if((localIStream == nil) || (localOStream == nil)) + { + DDLogError(@"failed to create streams"); + [self postError:NSLocalizedString(@"Unable to connect to server!", @"") withIsSevere:NO]; + [self reconnect]; + return; + } + else + DDLogInfo(@"streams created ok"); + + //open sockets, init pipe and start connecting (including TLS handshake if isDirectTLS==YES) + DDLogInfo(@"opening TCP streams"); + _pipeliningState = kPipelinedNothing; + [_oStream setDelegate:self]; + [_oStream scheduleInRunLoop:[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierNetwork] forMode:NSDefaultRunLoopMode]; + _iPipe = [[MLPipe alloc] initWithInputStream:localIStream andOuterDelegate:self]; + [localIStream open]; + [_oStream open]; + DDLogInfo(@"TCP streams opened"); + + //prepare xmpp parser (this is the first time for this connection --> we don't need to clear the receive queue) + [self prepareXMPPParser]; + + //MLStream will automatically use tcp fast open for direct tls connections + if(self.connectionProperties.server.isDirectTLS == YES) + { + [self startXMPPStreamWithXMLOpening:YES]; + + //pipeline auth request onto our stream header if we have cached stream features available + if(_cachedStreamFeaturesBeforeAuth != nil) + { + DDLogDebug(@"Pipelining auth using cached stream features: %@", _cachedStreamFeaturesBeforeAuth); + _pipeliningState = kPipelinedAuth; + [self handleFeaturesBeforeAuth:_cachedStreamFeaturesBeforeAuth]; + } + } + else + { + //send stream start and starttls nonza as tcp fastopen idempotent data if not in direct tls mode + //(this will concatenate everything to one single NSString queue entry) + //(not doing this will cause the network framework to only send the first queue entry (the xml opening) but not the stream start itself) + [self startXMPPStreamWithXMLOpening:YES withStartTLS:YES andDirectWrite:YES]; + } +} + +-(BOOL) connectionTask +{ + // allow override for server and port if one is specified for the account + if(![self.connectionProperties.server.host isEqual:@""]) + { + DDLogInfo(@"Ignoring SRV records for this connection, server manually configured: %@:%@", self.connectionProperties.server.connectServer, self.connectionProperties.server.connectPort); + [self createStreams]; + return NO; + } + + //already tried to connect once (e.g. _SRVDiscoveryDone==YES) and either all SRV records were tried or we didn't discovered any + if(_SRVDiscoveryDone && [_usableServersList count] == 0) + { + //in this condition, the registration failed + if(_registration || _registrationSubmission) + { + DDLogWarn(@"Could not connect for registering, publishing error..."); + [self postError:[NSString stringWithFormat:NSLocalizedString(@"Server for domain '%@' not responding!", @""), self.connectionProperties.identity.domain] withIsSevere:NO]; + return YES; + } + } + + // do DNS discovery if it hasn't already been set + if(!_SRVDiscoveryDone) + { + DDLogInfo(@"Querying for SRV records"); + _discoveredServersList = [[MLDNSLookup new] dnsDiscoverOnDomain:self.connectionProperties.identity.domain]; + _SRVDiscoveryDone = YES; + // no SRV records found, update server to directly connect to specified domain + if([_discoveredServersList count] == 0) + { + [self.connectionProperties.server updateConnectServer:self.connectionProperties.identity.domain]; + [self.connectionProperties.server updateConnectPort:@5222]; + [self.connectionProperties.server updateConnectTLS:NO]; + DDLogInfo(@"NO SRV records found, using standard xmpp config: %@:%@ (using starttls)", self.connectionProperties.server.connectServer, self.connectionProperties.server.connectPort); + } + } + + // Show warning when xmpp-client srv entry prohibits connections + for(NSDictionary* row in _discoveredServersList) + { + // Check if entry "." == srv target + if(![[row objectForKey:@"isEnabled"] boolValue]) + { + DDLogInfo(@"SRV entry prohibits XMPP connection for server %@", self.connectionProperties.identity.domain); + //this is not severe on registration, but severe otherwise + [self postError:[NSString stringWithFormat:NSLocalizedString(@"SRV entry prohibits XMPP connection for domain %@", @""), self.connectionProperties.identity.domain] withIsSevere:!(_registration || _registrationSubmission)]; + return YES; + } + } + + // if all servers have been tried start over with the first one again + if([_discoveredServersList count] > 0 && [_usableServersList count] == 0) + { + DDLogWarn(@"All %lu SRV dns records tried, starting over again", (unsigned long)[_discoveredServersList count]); + for(NSDictionary* row in _discoveredServersList) + DDLogInfo(@"SRV entry in _discoveredServersList: server=%@, port=%@, isSecure=%s, priority=%@, ttl=%@", + [row objectForKey:@"server"], + [row objectForKey:@"port"], + [[row objectForKey:@"isSecure"] boolValue] ? "YES" : "NO", + [row objectForKey:@"priority"], + [row objectForKey:@"ttl"] + ); + _usableServersList = [_discoveredServersList mutableCopy]; + } + + if([_usableServersList count] > 0) + { + DDLogInfo(@"Using connection parameters discovered via SRV dns record: server=%@, port=%@, isSecure=%s, priority=%@, ttl=%@", + [[_usableServersList objectAtIndex:0] objectForKey:@"server"], + [[_usableServersList objectAtIndex:0] objectForKey:@"port"], + [[[_usableServersList objectAtIndex:0] objectForKey:@"isSecure"] boolValue] ? "YES" : "NO", + [[_usableServersList objectAtIndex:0] objectForKey:@"priority"], + [[_usableServersList objectAtIndex:0] objectForKey:@"ttl"] + ); + [self.connectionProperties.server updateConnectServer: [[_usableServersList objectAtIndex:0] objectForKey:@"server"]]; + [self.connectionProperties.server updateConnectPort: [[_usableServersList objectAtIndex:0] objectForKey:@"port"]]; + [self.connectionProperties.server updateConnectTLS: [[[_usableServersList objectAtIndex:0] objectForKey:@"isSecure"] boolValue]]; + // remove this server so that the next connection attempt will try the next server in the list + [_usableServersList removeObjectAtIndex:0]; + DDLogInfo(@"%lu SRV entries left:", (unsigned long)[_usableServersList count]); + for(NSDictionary* row in _usableServersList) + DDLogInfo(@"SRV entry in _usableServersList: server=%@, port=%@, isSecure=%s, priority=%@, ttl=%@", + [row objectForKey:@"server"], + [row objectForKey:@"port"], + [[row objectForKey:@"isSecure"] boolValue] ? "YES" : "NO", + [row objectForKey:@"priority"], + [row objectForKey:@"ttl"] + ); + } + + [self createStreams]; + return NO; +} + +-(BOOL) parseQueueFrozen +{ + return [_parseQueue isSuspended] == YES; +} + +-(void) freezeParseQueue +{ + @synchronized(_parseQueue) { + //pause all timers before freezing the parse queue to not trigger timers that can not be handeld properly while frozen + [_loginTimer pause]; + [_pingTimer pause]; + [_reconnectTimer pause]; + + //don't do this in a block on the parse queue because the parse queue could potentially have a significant amount of blocks waiting + //to be synchronously dispatched to the receive queue and processed and we don't want to wait for all these stanzas to be processed + //and rather freeze the parse queue as soon as possible + _parseQueue.suspended = YES; + + //apparently setting _parseQueue.suspended = YES does return before the queue is actually suspended + //--> busy wait for _parseQueue.suspended == YES + [HelperTools busyWaitForOperationQueue:_parseQueue]; + MLAssert([self parseQueueFrozen] == YES, @"Parse queue not frozen after setting suspended to YES!"); + + //this has to be synchronous because we want to be sure no further stanzas are leaking from the parse queue + //into the receive queue once we leave this method + //--> wait for all blocks put into the receive queue by the parse queue right before it was frozen + [self dispatchOnReceiveQueue: ^{ + [HelperTools busyWaitForOperationQueue:self->_parseQueue]; + MLAssert([self parseQueueFrozen] == YES, @"Parse queue not frozen after setting suspended to YES (in receive queue)!"); + DDLogInfo(@"Parse queue is frozen now!"); + }]; + } +} + +-(void) unfreezeParseQueue +{ + @synchronized(_parseQueue) { + //this has to be synchronous because we want to be sure the parse queue is operating again once we leave this method + [self dispatchOnReceiveQueue: ^{ + self->_parseQueue.suspended = NO; + DDLogInfo(@"Parse queue is UNfrozen now!"); + }]; + + //resume all timers paused when freezing the parse queue + [_loginTimer resume]; + [_pingTimer resume]; + [_reconnectTimer resume]; + } +} + +-(void) freezeSendQueue +{ + if(_sendQueue.suspended) + { + DDLogWarn(@"Send queue of account %@ already frozen, doing nothing...", self); + return; + } + + //wait for all queued operations to finish (this will NOT block if the tcp stream is not writable) + [self->_sendQueue addOperations: @[[NSBlockOperation blockOperationWithBlock:^{ + self->_sendQueue.suspended = YES; + }]] waitUntilFinished:YES]; //block until finished because we are closing the socket directly afterwards + [HelperTools busyWaitForOperationQueue:_sendQueue]; +} + +-(void) unfreezeSendQueue +{ + //no need to dispatch anything here, just start processing jobs + self->_sendQueue.suspended = NO; +} + +-(void) freeze +{ + //this can only be done if this method is the only one that freezes the receive queue, + //because this shortcut assumes that parse and send queues are always frozen, too, if the receive queue is frozen + if(_receiveQueue.suspended) + { + DDLogWarn(@"Account %@ already frozen, doing nothing...", self); + return; + } + + DDLogInfo(@"Freezing account: %@", self); + + //this does not have to be synchronized with the freezing of the parse queue and receive queue + [self freezeSendQueue]; + + //don't merge the sync dispatch to freeze the receive queue with the sync dispatch done by freezeParseQueue + //merging those might leave some tasks in the receive queue that got added to it after the parse queue freeze + //was signalled but before it actually completed the freeze + //statement 1: + //this is not okay because leaked stanzas while frozen could be processed twice if the complete app gets frozen afterwards, + //then these stanzas get processed by the appex and afterwards the complete app and subsequently the receive queue gets unfrozen again + //statement 2: + //stanzas still in the parse queue when unfreezing the account will be dropped because self.accountState < kStateConnected + //will instruct the block inside prepareXMPPParser to drop any stanzas still queued in the parse queue + //and having even self.accountState < kStateReconnecting will make a call to [self connect] mandatory, + //which will cancel all operations still queued on the parse queue + //statement 3: + //normally a complete app freeze will only occur after calling [MLXMPPManager disconnectAll] and subsequently [xmpp freeze], + //so self.accountState < kStateReconnecting should always be true on unfreeze (which will make statement 2 above always hold true) + //statement 4: + //if an app freeze takes too long, for example because disconnecting does not finish in time, or if the app still holds the MLProcessLock, + //the app will be killed by iOS, which will immediately invalidate every block in every queue + [self freezeParseQueue]; + [self dispatchOnReceiveQueue:^{ + //this is the last block running in the receive queue (it will be frozen once this block finishes execution) + self->_receiveQueue.suspended = YES; + }]; + [HelperTools busyWaitForOperationQueue:_receiveQueue]; +} + +-(void) unfreeze +{ + DDLogInfo(@"Unfreezing account: %@", self); + + //make sure we don't have any race conditions by dispatching this to our receive queue + //this operation has highest priority to make sure it will be executed first once unfrozen + NSBlockOperation* unfreezeOperation = [NSBlockOperation blockOperationWithBlock:^{ + //this has to be the very first thing even before unfreezing the parse or send queues + //make sure to lock this against our explicitLogout, even if not reloading state + @synchronized(self->_stateLockObject) { + if(self.accountState < kStateReconnecting) + { + DDLogInfo(@"Reloading UNfrozen account %@", self.accountID); + //(re)read persisted state (could be changed by appex) + [self readState]; + } + else + DDLogInfo(@"Not reloading UNfrozen account %@, already connected", self.accountID); + + //this must be inside the dispatch async, because it will dispatch *SYNC* to the receive queue and potentially block or even deadlock the system + [self unfreezeParseQueue]; + + [self unfreezeSendQueue]; + } + }]; + unfreezeOperation.queuePriority = NSOperationQueuePriorityVeryHigh; //make sure this will become the first operation executed once unfrozen + [self->_receiveQueue addOperations: @[unfreezeOperation] waitUntilFinished:NO]; + + //unfreeze receive queue and execute block added above + self->_receiveQueue.suspended = NO; +} + +-(void) reinitLoginTimer +{ + //check if we are still logging in and abort here, if not (we don't want a new timer when we decided to not disconnect) + if(self->_accountState < kStateReconnecting) + return; + + //cancel old timer if existing and... + if(self->_loginTimer != nil) + [self->_loginTimer cancel]; + //...replace it with new timer + self->_loginTimer = createDelayableTimer(CONNECT_TIMEOUT, (^{ + self->_loginTimer = nil; + [self dispatchAsyncOnReceiveQueue: ^{ + DDLogInfo(@"Login took too long, cancelling and trying to reconnect (potentially using another SRV record)"); + [self reconnect]; + }]; + })); +} + +-(void) connect +{ + if([self parseQueueFrozen]) + { + DDLogWarn(@"Not trying to connect: parse queue frozen!"); + return; + } + + [self dispatchAsyncOnReceiveQueue: ^{ + if([self parseQueueFrozen]) + { + DDLogWarn(@"Not trying to connect: parse queue frozen!"); + return; + } + + [self->_parseQueue cancelAllOperations]; //throw away all parsed but not processed stanzas from old connections + [self unfreezeParseQueue]; //make sure the parse queue is operational again + //we don't want to loose outgoing messages by throwing away their receiveQueue operation adding them to the smacks queue etc. + //[self->_receiveQueue cancelAllOperations]; //stop everything coming after this (we will start a clean connect here!) + + //sanity check + if(self.accountState >= kStateReconnecting) + { + DDLogError(@"asymmetrical call to login without a teardown logout, calling reconnect..."); + [self reconnect]; + return; + } + + //make sure we are still enabled ("-1" is used for the account registration process and never saved to db) + if(self.accountID.intValue != -1 && ![[DataLayer sharedInstance] isAccountEnabled:self.accountID]) + { + DDLogError(@"Account '%@' not enabled anymore, ignoring login", self.accountID); + return; + } + + //mark this account as currently connecting + self->_accountState = kStateReconnecting; + + //only proceed with connection if not concurrent with other processes + DDLogVerbose(@"Checking remote process lock..."); + if(![HelperTools isAppExtension] && [MLProcessLock checkRemoteRunning:@"NotificationServiceExtension"]) + { + DDLogInfo(@"NotificationServiceExtension is running, waiting for its termination before connecting"); + [MLProcessLock waitForRemoteTermination:@"NotificationServiceExtension"]; + } + if([HelperTools isAppExtension] && [MLProcessLock checkRemoteRunning:@"MainApp"]) + { + DDLogInfo(@"MainApp is running, not connecting (this should transition us into idle state again which will terminate this extension)"); + self->_accountState = kStateDisconnected; + return; + } + + DDLogInfo(@"XMPP connnect start"); + self->_startTLSComplete = NO; + self->_catchupDone = NO; + + [self cleanupSendQueue]; + + DDLogVerbose(@"Removing scramHandler..."); + self->_scramHandler = nil; + self->_blockToCallOnTCPOpen = nil; + + //(re)read persisted state and start connection + [self readState]; + if([self connectionTask]) + { + DDLogError(@"Server disallows xmpp connections for account '%@', ignoring login", self.accountID); + self->_accountState = kStateDisconnected; + return; + } + + [self reinitLoginTimer]; + }]; +} + +-(void) disconnect +{ + [self disconnectWithStreamError:nil andExplicitLogout:NO]; +} + +-(void) disconnect:(BOOL) explicitLogout +{ + [self disconnectWithStreamError:nil andExplicitLogout:explicitLogout]; +} + +-(void) disconnectWithStreamError:(MLXMLNode* _Nullable) streamError +{ + [self disconnectWithStreamError:streamError andExplicitLogout:NO]; +} + +-(void) disconnectWithStreamError:(MLXMLNode* _Nullable) streamError andExplicitLogout:(BOOL) explicitLogout +{ + DDLogInfo(@"disconnect called..."); + //commonly used by shortcut outside of receive queue and called from inside the receive queue, too + monal_void_block_t doExplicitLogout = ^{ + @synchronized(self->_stateLockObject) { + DDLogVerbose(@"explicitLogout == YES --> clearing state"); + + //preserve unAckedStanzas even on explicitLogout and resend them on next connect + //if we don't do this, messages could get lost when logging out directly after sending them + //and: sending messages twice is less intrusive than silently loosing them + NSMutableArray* stanzas = self.unAckedStanzas; + + //reset smacks state to sane values (this can be done even if smacks is not supported) + [self initSM3]; + self.unAckedStanzas = stanzas; + + //inform all old iq handlers of invalidation and clear _iqHandlers dictionary afterwards + @synchronized(self->_iqHandlers) { + for(NSString* iqid in [self->_iqHandlers allKeys]) + { + DDLogWarn(@"Invalidating iq handler for iq id '%@'", iqid); + if(self->_iqHandlers[iqid][@"handler"] != nil) + $invalidate(self->_iqHandlers[iqid][@"handler"], $ID(account, self), $ID(reason, @"disconnect")); + else if(self->_iqHandlers[iqid][@"errorHandler"]) + ((monal_iq_handler_t)self->_iqHandlers[iqid][@"errorHandler"])(nil); + } + self->_iqHandlers = [NSMutableDictionary new]; + } + + //invalidate pubsub queue (*after* iq handlers that also might invalidate a result handler of the queued operation) + [self.pubsub invalidateQueue]; + + //clear pipeline cache + self->_pipeliningState = kPipelinedNothing; + self->_cachedStreamFeaturesBeforeAuth = nil; + self->_cachedStreamFeaturesAfterAuth = nil; + + //clear all reconnection handlers + @synchronized(self->_reconnectionHandlers) { + [self->_reconnectionHandlers removeAllObjects]; + } + + //persist these changes + [self persistState]; + } + + [[DataLayer sharedInstance] resetContactsForAccount:self.accountID]; + + //trigger view updates to make sure enabled/disabled account state propagates to all ui elements + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + }; + + //short-circuit common case without dispatching to receive queue + //this allows calling a noop disconnect while the receive queue is frozen + //every change to our state is locked by our _stateLockObject and the receive queue unfreeze uses this lock, too + //--> an unfreeze can not happen half way through this explicit logout and therefore can't corrupt any state + //--> an unfreeze is needed to dispatch to the receive queue which is used by our connect method + if(self->_accountState_loginTimer) + [self->_loginTimer cancel]; //cancel running login timer + self->_loginTimer = nil; + if(self->_pingTimer) + [self->_pingTimer cancel]; //cancel running ping timer + self->_pingTimer = nil; + if(self->_reconnectTimer) + [self->_reconnectTimer cancel]; //cancel running reconnect timer + self->_reconnectTimer = nil; + @synchronized(self->_timersToCancelOnDisconnect) { + for(monal_void_block_t timer in self->_timersToCancelOnDisconnect) + timer(); + [self->_timersToCancelOnDisconnect removeAllObjects]; + } + + DDLogVerbose(@"Removing scramHandler..."); + self->_scramHandler = nil; + self->_blockToCallOnTCPOpen = nil; + + if(self->_accountState_disconnectInProgres = YES; + + //invalidate all ephemeral iq handlers (those not surviving an app restart or switch to/from appex) + @synchronized(self->_iqHandlers) { + for(NSString* iqid in [self->_iqHandlers allKeys]) + { + if(self->_iqHandlers[iqid][@"handler"] == nil) + { + NSDictionary* data = (NSDictionary*)self->_iqHandlers[iqid]; + if(data[@"errorHandler"]) + { + DDLogWarn(@"Invalidating iq handler for iq id '%@'", iqid); + if(data[@"errorHandler"]) + ((monal_iq_handler_t)data[@"errorHandler"])(nil); + } + [self->_iqHandlers removeObjectForKey:iqid]; + } + } + } + + if(explicitLogout && self->_accountState>=kStateHasStream) + { + DDLogInfo(@"doing explicit logout (xmpp stream close)"); + self->_reconnectBackoffTime = 0.0; + [self unfreezeSendQueue]; //make sure the queue is operational again + if(self.accountState>=kStateBound) + [self->_sendQueue addOperations: @[[NSBlockOperation blockOperationWithBlock:^{ + //disable push for this node + if([self.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:push:0"]) + [self disablePush]; + [self sendLastAck]; + }]] waitUntilFinished:YES]; //block until finished because we are closing the xmpp stream directly afterwards + [self->_sendQueue addOperations: @[[NSBlockOperation blockOperationWithBlock:^{ + //close stream (either with error or normally) + if(streamError != nil) + [self writeToStream:streamError.XMLString]; // dont even bother queueing + MLXMLNode* stream = [[MLXMLNode alloc] initWithElement:@"/stream:stream"]; //hack to close stream + [self writeToStream:stream.XMLString]; // dont even bother queueing + }]] waitUntilFinished:YES]; //block until finished because we are closing the socket directly afterwards + + @synchronized(self->_stateLockObject) { + //preserve unAckedStanzas even on explicitLogout and resend them on next connect + //if we don't do this, messages could get lost when logging out directly after sending them + //and: sending messages twice is less intrusive than silently loosing them + NSMutableArray* stanzas = self.unAckedStanzas; + + //reset smacks state to sane values (this can be done even if smacks is not supported) + [self initSM3]; + self.unAckedStanzas = stanzas; + + //inform all old iq handlers of invalidation and clear _iqHandlers dictionary afterwards + @synchronized(self->_iqHandlers) { + for(NSString* iqid in [self->_iqHandlers allKeys]) + { + DDLogWarn(@"Invalidating iq handler for iq id '%@'", iqid); + if(self->_iqHandlers[iqid][@"handler"] != nil) + $invalidate(self->_iqHandlers[iqid][@"handler"], $ID(account, self), $ID(reason, @"disconnect")); + else if(self->_iqHandlers[iqid][@"errorHandler"]) + ((monal_iq_handler_t)self->_iqHandlers[iqid][@"errorHandler"])(nil); + } + self->_iqHandlers = [NSMutableDictionary new]; + } + + //invalidate pubsub queue (*after* iq handlers that also might invalidate a result handler of the queued operation) + [self.pubsub invalidateQueue]; + + //clear pipeline cache + self->_pipeliningState = kPipelinedNothing; + self->_cachedStreamFeaturesBeforeAuth = nil; + self->_cachedStreamFeaturesAfterAuth = nil; + + //clear all reconnection handlers + @synchronized(self->_reconnectionHandlers) { + [self->_reconnectionHandlers removeAllObjects]; + } + + //persist these changes + [self persistState]; + } + + [[DataLayer sharedInstance] resetContactsForAccount:self.accountID]; + + //trigger view updates to make sure enabled/disabled account state propagates to all ui elements + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + } + else + { + [self unfreezeSendQueue]; //make sure the queue is operational again + if(streamError != nil) + { + [self->_sendQueue addOperations: @[[NSBlockOperation blockOperationWithBlock:^{ + //close stream with error + [self writeToStream:streamError.XMLString]; // dont even bother queueing + MLXMLNode* stream = [[MLXMLNode alloc] initWithElement:@"/stream:stream"]; //hack to close stream + [self writeToStream:stream.XMLString]; // dont even bother queueing + }]] waitUntilFinished:YES]; //block until finished because we are closing the socket directly afterwards + } + else + { + //send one last ack before closing the stream (xep version 1.5.2) + if(self.accountState>=kStateBound) + { + [self->_sendQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + [self sendLastAck]; + }]] waitUntilFinished:YES]; //block until finished because we are closing the socket directly afterwards + } + } + + //persist these changes + [self persistState]; + } + + [self closeSocket]; + [self accountStatusChanged]; + self->_disconnectInProgres = NO; + + //make sure our idle state is rechecked + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalNotIdle object:self]; + }]; +} + +-(void) closeSocket +{ + [self dispatchOnReceiveQueue: ^{ + DDLogInfo(@"removing streams from runLoop and aborting parser"); + + //prevent any new read or write + if(self->_xmlParser != nil) + { + [self->_xmlParser setDelegate:nil]; + [self->_xmlParser abortParsing]; + self->_xmlParser = nil; + } + [self->_iPipe close]; + self->_iPipe = nil; + [self->_oStream setDelegate:nil]; + + //sadly closing the output stream does not unblock a hanging [_oStream write:maxLength:] call + //blocked by an ios/max runtime race condition with starttls + DDLogInfo(@"closing output stream"); + @try + { + [self->_oStream close]; + } + @catch(id theException) + { + DDLogError(@"Exception in ostream close"); + } + self->_oStream=nil; + + //clean up send queue now that the delegate was removed (_streamHasSpace can not switch to YES now) + [self cleanupSendQueue]; + + //remove from runloop *after* cleaning up sendQueue (maybe this fixes a rare crash) + [self->_oStream removeFromRunLoop:[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierNetwork] forMode:NSDefaultRunLoopMode]; + + DDLogInfo(@"resetting internal stream state to disconnected"); + self->_startTLSComplete = NO; + self->_catchupDone = NO; + self->_accountState = kStateDisconnected; + + [self->_parseQueue cancelAllOperations]; //throw away all parsed but not processed stanzas (we should have closed sockets then!) + //we don't throw away operations in the receive queue because they could be more than just stanzas + //(for example outgoing messages that should be written to the smacks queue instead of just vanishing in a void) + //all incoming stanzas in the receive queue will honor the _accountState being lower than kStateReconnecting and be dropped + }]; +} + +-(void) reconnect +{ + [self reconnectWithStreamError:nil]; +} + +-(void) reconnectWithStreamError:(MLXMLNode* _Nullable) streamError +{ + if(_reconnectInProgress) + { + DDLogInfo(@"Ignoring reconnect while one already in progress"); + return; + } + if(_reconnectBackoffTime == 0.0) + _reconnectBackoffTime = 0.5; + [self reconnectWithStreamError:streamError andWaitingTime:_reconnectBackoffTime]; + _reconnectBackoffTime = MIN(_reconnectBackoffTime + 0.5, 2.0); +} + +-(void) reconnect:(double) wait +{ + [self reconnectWithStreamError:nil andWaitingTime:wait]; +} + +-(void) reconnectWithStreamError:(MLXMLNode* _Nullable) streamError andWaitingTime:(double) wait +{ + DDLogInfo(@"reconnect called..."); + + if(_reconnectInProgress) + { + DDLogInfo(@"Ignoring reconnect while one already in progress"); + return; + } + + [self dispatchAsyncOnReceiveQueue: ^{ + DDLogInfo(@"reconnect starts"); + if(self->_reconnectInProgress) + { + DDLogInfo(@"Ignoring reconnect while one already in progress"); + return; + } + + self->_reconnectInProgress = YES; + [self disconnectWithStreamError:streamError andExplicitLogout:NO]; + + DDLogInfo(@"Trying to connect again in %G seconds...", wait); + self->_reconnectTimer = createDelayableTimer(wait, (^{ + self->_reconnectTimer = nil; + [self dispatchAsyncOnReceiveQueue: ^{ + //there may be another connect/login operation in progress triggered from reachability or another timer + if(self.accountState_reconnectInProgress = NO; + }]; + }), (^{ + DDLogInfo(@"Reconnect got aborted: %@", self); + self->_reconnectTimer = nil; + [self dispatchAsyncOnReceiveQueue: ^{ + self->_reconnectInProgress = NO; + }]; + })); + DDLogInfo(@"reconnect exits"); + }]; +} + +#pragma mark XMPP + +-(void) prepareXMPPParser +{ + BOOL appex = [HelperTools isAppExtension]; + if(_xmlParser!=nil) + { + DDLogInfo(@"%@: resetting old xml parser", self->_logtag); + [_xmlParser setDelegate:nil]; + [_xmlParser abortParsing]; + [_parseQueue cancelAllOperations]; //throw away all parsed but not processed stanzas (we aborted the parser right now) + } + if(!_baseParserDelegate) + { + DDLogInfo(@"%@: creating parser delegate", self->_logtag); + _baseParserDelegate = [[MLBasePaser alloc] initWithCompletion:^(MLXMLNode* _Nullable parsedStanza) { + DDLogVerbose(@"%@: Parse finished for new <%@> stanza...", self->_logtag, parsedStanza.element); + + //don't parse any more if we reached > 50 stanzas already parsed and waiting in parse queue + //this makes ure we don't need to much memory while parsing a flood of stanzas and, in theory, + //should create a backpressure ino the tcp stream, too + //the calculated sleep time gives every stanza in the queue ~10ms to be handled (based on statistics) + BOOL wasSleeping = NO; + while(self.accountState >= kStateConnected) + { + //use a much smaller limit while in appex because memory there is limited to ~32MiB + unsigned long operationCount = [self->_parseQueue operationCount]; + double usedMemory = [HelperTools report_memory]; + if(!(operationCount > 50 || (appex && usedMemory > 16 && operationCount > MAX(2, 24 - usedMemory)))) + break; + + double waittime = (double)[self->_parseQueue operationCount] / 100.0; + DDLogInfo(@"%@: Sleeping %f seconds because parse queue has %lu entries and used/available memory: %.3fMiB / %.3fMiB...", self->_logtag, waittime, (unsigned long)[self->_parseQueue operationCount], usedMemory, (CGFloat)os_proc_available_memory() / 1048576); + [NSThread sleepForTimeInterval:waittime]; + wasSleeping = YES; + } + if(wasSleeping) + DDLogInfo(@"%@: Sleeping has ended, parse queue has %lu entries and used/available memory: %.3fMiB / %.3fMiB...", self->_logtag, (unsigned long)[self->_parseQueue operationCount], [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); + + if(self.accountState < kStateConnected) + { + DDLogWarn(@"%@: Throwing away incoming stanza *before* queueing in parse queue, accountState < kStateConnected", self->_logtag); + return; + } + +#ifndef QueryStatistics + //prime query cache by doing the most used queries in this thread ahead of the receiveQueue processing + //only preprocess MLXMLNode queries to prime the cache if enough xml nodes are already queued + //(we don't want to slow down processing by this) + if([self->_parseQueue operationCount] > 2) + { + //this list contains the upper part of the 0.75 percentile of the statistically most used queries + [parsedStanza find:@"/@id"]; + [parsedStanza find:@"/{urn:xmpp:sm:3}r"]; + [parsedStanza find:@"/{urn:xmpp:sm:3}a"]; + [parsedStanza find:@"/"]; + [parsedStanza find:@"/"]; + [parsedStanza find:@"/"]; + [parsedStanza find:@"/"]; + [parsedStanza find:@"{urn:xmpp:sid:0}origin-id"]; + [parsedStanza find:@"/{jabber:client}presence"]; + [parsedStanza find:@"/{jabber:client}message"]; + [parsedStanza find:@"/@h|int"]; + [parsedStanza find:@"{urn:xmpp:delay}delay"]; + [parsedStanza find:@"{http://jabber.org/protocol/muc#user}x/invite"]; + [parsedStanza find:@"//{http://jabber.org/protocol/pubsub#event}event"]; + [parsedStanza find:@"{urn:xmpp:receipts}received@id"]; + [parsedStanza find:@"{http://jabber.org/protocol/chatstates}*"]; + [parsedStanza find:@"{eu.siacs.conversations.axolotl}encrypted/payload"]; + [parsedStanza find:@"{urn:xmpp:sid:0}stanza-id@by"]; + [parsedStanza find:@"{urn:xmpp:mam:2}result"]; + [parsedStanza find:@"{urn:xmpp:chat-markers:0}displayed@id"]; + [parsedStanza find:@"body"]; + [parsedStanza find:@"{urn:xmpp:mam:2}result@id"]; + [parsedStanza find:@"{urn:xmpp:carbons:2}*"]; + } +#endif + + //queue up new stanzas onto the parseQueue which will dispatch them synchronously to the receiveQueue + //this makes it possible to discard all not already processed but parsed stanzas on disconnect or stream restart etc. + [self->_parseQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + //always process stanzas in the receiveQueue + //use a synchronous dispatch to make sure no (old) tcp buffers of disconnected connections leak into the receive queue on app unfreeze + DDLogVerbose(@"Synchronously handling next stanza on receive queue (%lu stanzas queued in parse queue, %lu current operations in receive queue, %.3fMiB / %.3fMiB memory used / available)", [self->_parseQueue operationCount], [self->_receiveQueue operationCount], [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); + [self->_receiveQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + if(self.accountState < kStateConnected) + { + DDLogWarn(@"Throwing away incoming stanza queued in parse queue, accountState < kStateConnected"); + return; + } + [MLNotificationQueue queueNotificationsInBlock:^{ + //add whole processing of incoming stanzas to one big transaction + //this will make it impossible to leave inconsistent database entries on app crashes or iphone crashes/reboots + DDLogVerbose(@"Starting transaction for: %@", parsedStanza); + [[DataLayer sharedInstance] createTransaction:^{ + DDLogVerbose(@"Started transaction for: %@", parsedStanza); + //don't write data to our tcp stream while inside this db transaction (all effects to the outside world should be transactional, too) + [self freezeSendQueue]; + [self processInput:parsedStanza withDelayedReplay:NO]; + DDLogVerbose(@"Ending transaction for: %@", parsedStanza); + }]; + DDLogVerbose(@"Ended transaction for: %@", parsedStanza); + [self unfreezeSendQueue]; //this will flush all stanzas added inside the db transaction and now waiting in the send queue + } onQueue:@"receiveQueue"]; + [self persistState]; //make sure to persist all state changes triggered by the events in the notification queue + }]] waitUntilFinished:YES]; + //we have to wait for the stanza/nonza to be handled before parsing the next one to not introduce race conditions + //between the response to our pipelined stream restart and the parser reset in the sasl success handler + }]] waitUntilFinished:(self->_accountState < kStateBound ? YES : NO)]; + }]; + } + else + { + DDLogInfo(@"%@: resetting parser delegate", self->_logtag); + [_baseParserDelegate reset]; + } + + // create (new) pipe and attach a (new) streaming parser + _xmlParser = [[NSXMLParser alloc] initWithStream:[_iPipe getNewOutputStream]]; + [_xmlParser setShouldProcessNamespaces:YES]; + [_xmlParser setShouldReportNamespacePrefixes:NO]; + //[_xmlParser setShouldReportNamespacePrefixes:YES]; //for debugging only + [_xmlParser setShouldResolveExternalEntities:NO]; + [_xmlParser setDelegate:_baseParserDelegate]; + + // do the stanza parsing in the low priority (=utility) global queue + dispatch_async(dispatch_queue_create_with_target([NSString stringWithFormat:@"im.monal.xmlparser%@", self->_logtag].UTF8String, DISPATCH_QUEUE_SERIAL, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0)), ^{ + DDLogInfo(@"%@: calling parse", self->_logtag); + [self->_xmlParser parse]; //blocking operation + DDLogInfo(@"%@: parse ended", self->_logtag); + }); +} + +-(void) startXMPPStreamWithXMLOpening:(BOOL) withXMLOpening +{ + return [self startXMPPStreamWithXMLOpening:withXMLOpening withStartTLS:NO andDirectWrite:NO]; +} + +-(void) startXMPPStreamWithXMLOpening:(BOOL) withXMLOpening withStartTLS:(BOOL) withStartTLS andDirectWrite:(BOOL) directWrite +{ + MLXMLNode* xmlOpening = [[MLXMLNode alloc] initWithElement:@"__xml"]; + MLXMLNode* stream = [[MLXMLNode alloc] initWithElement:@"stream:stream" andNamespace:@"jabber:client" withAttributes:@{ + @"xmlns:stream": @"http://etherx.jabber.org/streams", + @"version": @"1.0", + @"to": self.connectionProperties.identity.domain, + } andChildren:@[] andData:nil]; + //only set from-attribute if TLS is already established + if(self.connectionProperties.server.isDirectTLS || self->_startTLSComplete) + stream.attributes[@"from"] = self.connectionProperties.identity.jid; + //ignore starttls stream feature presence and opportunistically try starttls even before receiving the stream features + //(this is in accordance to RFC 7590: https://tools.ietf.org/html/rfc7590#section-3.1 ) + MLXMLNode* startTLS = [[MLXMLNode alloc] initWithElement:@"starttls" andNamespace:@"urn:ietf:params:xml:ns:xmpp-tls"]; + + if(directWrite) + { + //log stanzas being sent as idempotent data + if(withXMLOpening) + [self logStanza:xmlOpening withPrefix:@"IDEMPOTENT_SEND"]; + [self logStanza:stream withPrefix:@"IDEMPOTENT_SEND"]; + if(withStartTLS) + [self logStanza:startTLS withPrefix:@"IDEMPOTENT_SEND"]; + + //concatenate everything and directly write it as one single string, wait until this is finished to make sure + //the direct write is complete when returning from here (not strictly needed, but done for good measure) + [self->_sendQueue addOperations: @[[NSBlockOperation blockOperationWithBlock:^{ + [self->_outputQueue addObject:[NSString stringWithFormat:@"%@%@%@", + (withXMLOpening ? xmlOpening.XMLString : @""), + stream.XMLString, + (withStartTLS ? startTLS.XMLString : @"") + ]]; + [self writeFromQueue]; // try to send if there is space + }]] waitUntilFinished:YES]; + } + else + { + if(withXMLOpening) + [self send:xmlOpening]; + [self send:stream]; + if(withStartTLS) + [self send:startTLS]; + } +} + +-(void) sendPing:(double) timeout +{ + DDLogVerbose(@"sendPing called"); + [self dispatchAsyncOnReceiveQueue: ^{ + DDLogVerbose(@"sendPing called - now inside receiveQueue"); + + //make sure we are enabled before doing anything + if(![[DataLayer sharedInstance] isAccountEnabled:self.accountID]) + { + DDLogInfo(@"account is disabled, ignoring ping."); + return; + } + + if(self.accountState_pingTimer) + { + DDLogInfo(@"ping already sent, ignoring second ping request."); + return; + } + else if([self->_parseQueue operationCount] > 4) + { + DDLogWarn(@"parseQueue overflow, delaying ping by 4 seconds."); + @synchronized(self->_timersToCancelOnDisconnect) { + [self->_timersToCancelOnDisconnect addObject:createTimer(4.0, (^{ + DDLogDebug(@"ping delay expired, retrying ping."); + [self sendPing:timeout]; + }))]; + } + } + else + { + //start ping timer + self->_pingTimer = createDelayableTimer(timeout, (^{ + self->_pingTimer = nil; + [self dispatchAsyncOnReceiveQueue: ^{ + //check if someone already called reconnect or disconnect while we were waiting for the ping + //(which was called while we still were >= kStateBound) + if(self.accountState_pingTimer) + { + [self->_pingTimer cancel]; //cancel timer (ping was successful) + self->_pingTimer = nil; + } + }; + + //always use smacks pings if supported (they are shorter and better than iq pings) + if(self.connectionProperties.supportsSM3) + { + DDLogVerbose(@"calling pinging requestSMAck..."); + [self requestSMAck:YES]; + [self addSmacksHandler:handler]; + } + else + { + DDLogVerbose(@"sending out XEP-0199 ping..."); + //send xmpp ping even if server does not support it + //(the ping iq will get an error response then, which is as good as a normal iq response here) + XMPPIQ* ping = [[XMPPIQ alloc] initWithType:kiqGetType]; + [ping setiqTo:self.connectionProperties.identity.domain]; + [ping setPing]; + [self sendIq:ping withResponseHandler:^(XMPPIQ* result __unused) { + handler(); + } andErrorHandler:^(XMPPIQ* error) { + handler(); + }]; + } + } + }]; +} + +#pragma mark smacks + +-(void) addSmacksHandler:(monal_void_block_t) handler +{ + @synchronized(_stateLockObject) { + [self addSmacksHandler:handler forValue:self.lastOutboundStanza]; + } +} + +-(void) addSmacksHandler:(monal_void_block_t) handler forValue:(NSNumber*) value +{ + @synchronized(_stateLockObject) { + if([value integerValue] < [self.lastOutboundStanza integerValue]) + { + @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Trying to add smacks handler for value *SMALLER* than current self.lastOutboundStanza, this handler would *never* be triggered!" userInfo:@{ + @"lastOutboundStanza": self.lastOutboundStanza, + @"value": value, + }]; + } + NSDictionary* dic = @{@"value":value, @"handler":handler}; + [_smacksAckHandler addObject:dic]; + } +} + +-(void) resendUnackedStanzas +{ + @synchronized(_stateLockObject) { + DDLogInfo(@"Resending unacked stanzas..."); + NSMutableArray* sendCopy = [[NSMutableArray alloc] initWithArray:self.unAckedStanzas]; + //remove all stanzas from queue and correct the lastOutboundStanza counter accordingly + self.lastOutboundStanza = [NSNumber numberWithInteger:[self.lastOutboundStanza integerValue] - [self.unAckedStanzas count]]; + //Send appends to the unacked stanzas. Not removing it now will create an infinite loop. + //It may also result in mutation on iteration + [self.unAckedStanzas removeAllObjects]; + for(NSDictionary* dic in sendCopy) + [self send:(XMPPStanza*)[dic objectForKey:kStanza]]; + DDLogInfo(@"Done resending unacked stanzas..."); + [self persistState]; + } +} + +-(void) resendUnackedMessageStanzasOnly:(NSMutableArray*) stanzas +{ + if(stanzas) + { + @synchronized(_stateLockObject) { + DDLogWarn(@"Resending unacked message stanzas only..."); + NSMutableArray* sendCopy = [[NSMutableArray alloc] initWithArray:stanzas]; + //clear queue because we don't want to repeat resending these stanzas later if the var stanzas points to self.unAckedStanzas here + [stanzas removeAllObjects]; + for(NSDictionary* dic in sendCopy) + { + XMPPStanza* stanza = [dic objectForKey:kStanza]; + //only resend message stanzas because of the smacks error condition + //but don't add them to our outgoing smacks queue again, if smacks isn't supported + if([stanza.element isEqualToString:@"message"]) + [self send:stanza withSmacks:self.connectionProperties.supportsSM3]; + } + //persist these changes, the queue can now be empty (because smacks enable failed) + //or contain all the resent stanzas (e.g. only resume failed) + [self persistState]; + } + } +} + +-(BOOL) shouldTriggerSyncErrorForImportantUnackedOutgoingStanzas +{ + @synchronized(_stateLockObject) { + DDLogInfo(@"Checking for important unacked stanzas..."); + for(NSDictionary* dic in self.unAckedStanzas) + { + MLXMLNode* xmlNode = [dic objectForKey:kStanza]; + //nonzas are not important here + if(![xmlNode isKindOfClass:[XMPPStanza class]]) + continue; + XMPPStanza* stanza = (XMPPStanza*)xmlNode; + //important stanzas are message stanzas containing a body element + if([stanza.element isEqualToString:@"message"] && [stanza check:@"body"]) + return YES; + } + } + //no important stanzas found + return NO; +} + +-(BOOL) removeAckedStanzasFromQueue:(NSNumber*) hvalue +{ + NSMutableArray* ackHandlerToCall = [[NSMutableArray alloc] initWithCapacity:[_smacksAckHandler count]]; + @synchronized(_stateLockObject) { + //stanza counting bugs on the server are fatal + if(([hvalue unsignedIntValue] - [self.lastHandledOutboundStanza unsignedIntValue]) > [self.unAckedStanzas count]) + { + self.streamID = nil; //we don't ever want to resume this + NSString* message = @"Server acknowledged more stanzas than sent by client"; + DDLogError(@"Stream error: %@", message); + [self postError:message withIsSevere:NO]; + MLXMLNode* streamError = [[MLXMLNode alloc] initWithElement:@"stream:error" withAttributes:@{@"type": @"cancel"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"undefined-condition" andNamespace:@"urn:ietf:params:xml:ns:xmpp-streams" withAttributes:@{} andChildren:@[] andData:nil], + [[MLXMLNode alloc] initWithElement:@"handled-count-too-high" andNamespace:@"urn:xmpp:sm:3" withAttributes:@{ + @"h": [hvalue stringValue], + @"send-count": [self.lastOutboundStanza stringValue], + } andChildren:@[] andData:nil], + [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-streams" withAttributes:@{} andChildren:@[] andData:message], + ] andData:nil]; + [self reconnectWithStreamError:streamError]; + return YES; + } + //stanza counting bugs on the server are fatal + if([hvalue unsignedIntValue] < [self.lastHandledOutboundStanza unsignedIntValue]) + { + self.streamID = nil; //we don't ever want to resume this + NSString* message = @"Server acknowledged less stanzas than last time"; + DDLogError(@"Stream error: %@", message); + [self postError:message withIsSevere:NO]; + MLXMLNode* streamError = [[MLXMLNode alloc] initWithElement:@"stream:error" withAttributes:@{@"type": @"cancel"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"undefined-condition" andNamespace:@"urn:ietf:params:xml:ns:xmpp-streams" withAttributes:@{} andChildren:@[] andData:nil], + [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-streams" withAttributes:@{} andChildren:@[] andData:message], + ] andData:nil]; + [self reconnectWithStreamError:streamError]; + return YES; + } + + self.lastHandledOutboundStanza = hvalue; + if([self.unAckedStanzas count]>0) + { + NSMutableArray* iterationArray = [[NSMutableArray alloc] initWithArray:self.unAckedStanzas]; + DDLogDebug(@"removeAckedStanzasFromQueue: hvalue %@, lastOutboundStanza %@", hvalue, self.lastOutboundStanza); + NSMutableArray* discard = [[NSMutableArray alloc] initWithCapacity:[self.unAckedStanzas count]]; + for(NSDictionary* dic in iterationArray) + { + NSNumber* stanzaNumber = [dic objectForKey:kQueueID]; + MLXMLNode* node = [dic objectForKey:kStanza]; + //having a h value of 1 means the first stanza was acked and the first stanza has a kQueueID of 0 + if([stanzaNumber integerValue]<[hvalue integerValue]) + { + [discard addObject:dic]; + + //signal successful delivery to the server to all notification listeners + //(NOT the successful delivery to the receiving client, see the implementation of XEP-0184 for that) + if([node isKindOfClass:[XMPPMessage class]]) + { + XMPPMessage* messageNode = (XMPPMessage*)node; + if(messageNode.id) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalSentMessageNotice object:self userInfo:@{@"message":messageNode}]; + } + } + } + + [iterationArray removeObjectsInArray:discard]; + self.unAckedStanzas = iterationArray; + + //persist these changes (but only if we actually made some changes) + if([discard count]) + [self persistState]; + } + + DDLogVerbose(@"_smacksAckHandler: %@", _smacksAckHandler); + //remove registered smacksAckHandler that will be called now + for(NSDictionary* dic in _smacksAckHandler) + if([[dic objectForKey:@"value"] integerValue] <= [hvalue integerValue]) + { + DDLogVerbose(@"Adding smacks ack handler to call list: %@", dic); + [ackHandlerToCall addObject:dic]; + } + [_smacksAckHandler removeObjectsInArray:ackHandlerToCall]; + } + + //call registered smacksAckHandler that got sorted out + for(NSDictionary* dic in ackHandlerToCall) + { + DDLogVerbose(@"Now calling smacks ack handler: %@", dic); + ((monal_void_block_t)dic[@"handler"])(); + } + + return NO; +} + +-(void) requestSMAck:(BOOL) force +{ + //caution: this could be called from sendQueue, too! + MLXMLNode* rNode; + @synchronized(_stateLockObject) { + unsigned long unackedCount = (unsigned long)[self.unAckedStanzas count]; + if(self.accountState>=kStateBound && self.connectionProperties.supportsSM3 && + ((!self.smacksRequestInFlight && unackedCount>0) || force) + ) { + DDLogVerbose(@"requesting smacks ack..."); + rNode = [[MLXMLNode alloc] initWithElement:@"r" andNamespace:@"urn:xmpp:sm:3" withAttributes:@{} andChildren:@[] andData:nil]; + self.smacksRequestInFlight = YES; + } + else + DDLogDebug(@"no smacks request, there is nothing pending or a request already in flight..."); + } + if(rNode) + [self send:rNode]; +} + +-(void) sendLastAck +{ + //send last smacks ack as required by smacks revision 1.5.2 + if(self.connectionProperties.supportsSM3) + { + DDLogInfo(@"sending last ack"); + [self sendSMAck:NO]; + } +} + +-(void) sendSMAck:(BOOL) queuedSend +{ + //don't send anything before a resource is bound + if(self.accountState_catchupStanzaCounter++; + + //restart logintimer for every incoming stanza when not logged in (don't do anything without a running timer) + if(!delayedReplay && _loginTimer != nil && self->_accountState < kStateLoggedIn) + [self reinitLoginTimer]; + + //only process most stanzas/nonzas after having a secure context + if(self.connectionProperties.server.isDirectTLS || self->_startTLSComplete) + { + if([parsedStanza check:@"/{urn:xmpp:sm:3}r"] && self.connectionProperties.supportsSM3 && self.accountState>=kStateBound) + { + [self sendSMAck:YES]; + } + else if([parsedStanza check:@"/{urn:xmpp:sm:3}a"] && self.connectionProperties.supportsSM3 && self.accountState>=kStateBound) + { + NSNumber* h = [parsedStanza findFirst:@"/@h|int"]; + if(h==nil) + return [self invalidXMLError]; + + @synchronized(_stateLockObject) { + //remove acked messages + [self removeAckedStanzasFromQueue:h]; + + self.smacksRequestInFlight = NO; //ack returned + [self requestSMAck:NO]; //request ack again (will only happen if queue is not empty) + } + } + else if([parsedStanza check:@"/{jabber:client}presence"]) + { + XMPPPresence* presenceNode = (XMPPPresence*)parsedStanza; + + //sanity: check if presence from and to attributes are valid and throw it away if not + if([@"" isEqualToString:presenceNode.from] || [@"" isEqualToString:presenceNode.to] || [presenceNode.fromHost containsString:@"@"] || [presenceNode.toHost containsString:@"@"]) + { + DDLogError(@"sanity check failed for presence node, ignoring presence: %@", presenceNode); + //mark stanza as handled even if we don't process it further (we still received it, so we have to count it) + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + //sanitize: no from or to always means own bare/full jid + if(!presenceNode.from) + presenceNode.from = self.connectionProperties.identity.jid; + if(!presenceNode.to) + presenceNode.to = self.connectionProperties.identity.fullJid; + + //sanity: check if toUser points to us and throw it away if not + if(![self.connectionProperties.identity.jid isEqualToString:presenceNode.toUser]) + { + DDLogError(@"sanity check failed presence node, ignoring presence: %@", presenceNode); + //mark stanza as handled even if we don't process it further (we still received it, so we have to count it) + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + MLContact* contact = [MLContact createContactFromJid:presenceNode.fromUser andAccountID:self.accountID]; + if([presenceNode.fromUser isEqualToString:self.connectionProperties.identity.jid]) + { + DDLogInfo(@"got self presence"); + + //ignore special presences for status updates (they don't have one) + if(![presenceNode check:@"/@type"]) + { + NSMutableDictionary* accountDetails = [[DataLayer sharedInstance] detailsForAccount:self.accountID]; + accountDetails[@"statusMessage"] = [presenceNode check:@"status#"] ? [presenceNode findFirst:@"status#"] : @""; + [[DataLayer sharedInstance] updateAccounWithDictionary:accountDetails]; + } + } + else + { + if([presenceNode check:@"/"]) + { + // check if we need a contact request + NSDictionary* contactSub = [[DataLayer sharedInstance] getSubscriptionForContact:contact.contactJid andAccount:contact.accountID]; + DDLogVerbose(@"Got subscription request for contact %@ having subscription status: %@", presenceNode.fromUser, contactSub); + if(!contactSub || !([[contactSub objectForKey:@"subscription"] isEqualToString:kSubTo] || [[contactSub objectForKey:@"subscription"] isEqualToString:kSubBoth])) + [[DataLayer sharedInstance] addContactRequest:contact]; + else if(contactSub && [[contactSub objectForKey:@"subscription"] isEqualToString:kSubTo]) + [self addToRoster:contact withPreauthToken:nil]; + + //wait 1 sec for nickname and profile image to be processed, then send out kMonalContactRefresh notification + createTimer(1.0, (^{ + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self userInfo:@{ + @"contact": [MLContact createContactFromJid:presenceNode.fromUser andAccountID:self.accountID] + }]; + })); + } + + if([presenceNode check:@"/"]) + { + // check if we need a contact request + NSDictionary* contactSub = [[DataLayer sharedInstance] getSubscriptionForContact:contact.contactJid andAccount:contact.accountID]; + DDLogVerbose(@"Got unsubscribe request of contact %@ having subscription status: %@", presenceNode.fromUser, contactSub); + [[DataLayer sharedInstance] deleteContactRequest:contact]; + + //wait 1 sec for nickname and profile image to be processed, then send out kMonalContactRefresh notification + createTimer(1.0, (^{ + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self userInfo:@{ + @"contact": [MLContact createContactFromJid:presenceNode.fromUser andAccountID:self.accountID] + }]; + })); + } + + if(contact.isMuc || [presenceNode check:@"{http://jabber.org/protocol/muc#user}x"] || [presenceNode check:@"{http://jabber.org/protocol/muc}x"]) + { + //only handle presences for mucs we know + if([[DataLayer sharedInstance] isBuddyMuc:presenceNode.fromUser forAccount:self.accountID]) + [self.mucProcessor processPresence:presenceNode]; + else + DDLogError(@"Got presence of unknown muc %@, ignoring...", presenceNode.fromUser); + + //mark this stanza as handled + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + if(![[HelperTools defaultsDB] boolForKey: @"allowNonRosterContacts"] && !contact.isSubscribedFrom) + { + //mark this stanza as handled + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + if(![presenceNode check:@"/@type"]) + { + DDLogVerbose(@"presence notice from %@", presenceNode.fromUser); + if(contact.isMuc) + [self.mucProcessor processPresence:presenceNode]; + else + { + contact.state = [presenceNode findFirst:@"show#"]; + contact.statusMessage = [presenceNode findFirst:@"status#"]; + + //add contact if possible (ignore already existing contacts) + [[DataLayer sharedInstance] addContact:presenceNode.fromUser forAccount:self.accountID nickname:nil]; + + //clear the state field in db and reset the ver hash for this resource + [[DataLayer sharedInstance] setOnlineBuddy:presenceNode forAccount:self.accountID]; + + //update buddy state + [[DataLayer sharedInstance] setBuddyState:presenceNode forAccount:self.accountID]; + [[DataLayer sharedInstance] setBuddyStatus:presenceNode forAccount:self.accountID]; + + [[MLNotificationQueue currentQueue] postNotificationName:kMonalNewPresenceNotice object:self userInfo:@{ + @"jid": presenceNode.fromUser, + @"accountID": self.accountID, + @"resource": nilWrapper(presenceNode.fromResource), + @"available": @YES, + }]; + } + } + else if([presenceNode check:@"/"]) + { + DDLogVerbose(@"Updating lastInteraction from unavailable presence..."); + [[DataLayer sharedInstance] setOfflineBuddy:presenceNode forAccount:self.accountID]; + + [[MLNotificationQueue currentQueue] postNotificationName:kMonalNewPresenceNotice object:self userInfo:@{ + @"jid": presenceNode.fromUser, + @"accountID": self.accountID, + @"resource": nilWrapper(presenceNode.fromResource), + @"available": @NO, + }]; + + //inform other parts of our system that the lastInteraction timestamp has potentially changed + //(e.g. no supporting resource online anymore) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalLastInteractionUpdatedNotice object:self userInfo:@{ + @"jid": presenceNode.fromUser, + @"accountID": self.accountID, + @"lastInteraction": nilWrapper([[DataLayer sharedInstance] lastInteractionOfJid:presenceNode.fromUser forAccountID:self.accountID]), + @"isTyping": @NO, + @"resource": nilWrapper(presenceNode.fromResource), + }]; + } + + //handle entity capabilities (this has to be done *after* setOnlineBuddy which sets the ver hash for the resource to "") + if( + [presenceNode check:@"{http://jabber.org/protocol/caps}c@hash"] && + [presenceNode check:@"{http://jabber.org/protocol/caps}c@ver"] && + presenceNode.fromUser && + presenceNode.fromResource + ) + { + NSString* newVer = [presenceNode findFirst:@"{http://jabber.org/protocol/caps}c@ver"]; + BOOL shouldQueryCaps = NO; + if(![@"sha-1" isEqualToString:[presenceNode findFirst:@"{http://jabber.org/protocol/caps}c@hash"]]) + { + DDLogWarn(@"Unknown caps hash algo '%@', requesting disco query without checking hash!", [presenceNode findFirst:@"{http://jabber.org/protocol/caps}c@hash"]); + shouldQueryCaps = YES; + } + else + { + NSString* ver = [[DataLayer sharedInstance] getVerForUser:presenceNode.fromUser andResource:presenceNode.fromResource onAccountID:self.accountID]; + if(!ver || ![ver isEqualToString:newVer]) //caps hash of resource changed + [[DataLayer sharedInstance] setVer:newVer forUser:presenceNode.fromUser andResource:presenceNode.fromResource onAccountID:self.accountID]; + + if(![[DataLayer sharedInstance] getCapsforVer:newVer onAccountID:self.accountID]) + { + DDLogInfo(@"Presence included unknown caps hash %@, requesting disco query", newVer); + shouldQueryCaps = YES; + } + } + + if(shouldQueryCaps) + { + if([_runningCapsQueries containsObject:newVer]) + DDLogInfo(@"Presence included unknown caps hash %@, but disco query already running, not querying again", newVer); + else + { + DDLogInfo(@"Querying disco for caps hash: %@", newVer); + XMPPIQ* discoInfo = [[XMPPIQ alloc] initWithType:kiqGetType]; + [discoInfo setiqTo:presenceNode.from]; + [discoInfo setDiscoInfoNode]; + [self sendIq:discoInfo withHandler:$newHandler(MLIQProcessor, handleEntityCapsDisco)]; + [_runningCapsQueries addObject:newVer]; + } + } + } + + //handle last interaction time (this must be done *after* parsing the ver attribute to get the cached capabilities) + //but only do so if the urn:xmpp:idle:1 was supported by that resource (e.g. don't send out unneeded updates) + if(![presenceNode check:@"/@type"] && presenceNode.fromResource && [[DataLayer sharedInstance] checkCap:@"urn:xmpp:idle:1" forUser:presenceNode.fromUser andResource:presenceNode.fromResource onAccountID:self.accountID]) + { + DDLogVerbose(@"Updating lastInteraction from normal presence..."); + //findFirst: will return nil for lastInteraction = "online" --> DataLayer will handle that correctly + [[DataLayer sharedInstance] setLastInteraction:[presenceNode findFirst:@"{urn:xmpp:idle:1}idle@since|datetime"] forJid:presenceNode.fromUser andResource:presenceNode.fromResource onAccountID:self.accountID]; + + //inform other parts of our system that the lastInteraction timestamp has changed + [[MLNotificationQueue currentQueue] postNotificationName:kMonalLastInteractionUpdatedNotice object:self userInfo:@{ + @"jid": presenceNode.fromUser, + @"accountID": self.accountID, + @"lastInteraction": nilWrapper([[DataLayer sharedInstance] lastInteractionOfJid:presenceNode.fromUser forAccountID:self.accountID]), + @"isTyping": @NO, + @"resource": nilWrapper(presenceNode.fromResource), + }]; + } + } + + //only mark stanza as handled *after* processing it + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + } + else if([parsedStanza check:@"/{jabber:client}message"]) + { + //outerMessageNode and messageNode are the same for messages not carrying a carbon copy or mam result + XMPPMessage* originalParsedStanza = (XMPPMessage*)[parsedStanza copy]; + XMPPMessage* outerMessageNode = (XMPPMessage*)parsedStanza; + XMPPMessage* messageNode = outerMessageNode; + + //sanity: check if outer message from and to attributes are valid and throw it away if not + if([@"" isEqualToString:outerMessageNode.from] || [@"" isEqualToString:outerMessageNode.to] || [outerMessageNode.fromHost containsString:@"@"] || [outerMessageNode.toHost containsString:@"@"]) + { + DDLogError(@"sanity check failed for outer message node, ignoring message: %@", outerMessageNode); + //mark stanza as handled even if we don't process it further (we still received it, so we have to count it) + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + //sanitize outer node: no from or to always means own bare/full jid + if(!outerMessageNode.from) + outerMessageNode.from = self.connectionProperties.identity.jid; + if(!outerMessageNode.to) + outerMessageNode.to = self.connectionProperties.identity.fullJid; + + //sanity: check if toUser points to us and throw it away if not + if(![self.connectionProperties.identity.jid isEqualToString:outerMessageNode.toUser]) + { + DDLogError(@"sanity check failed for outer message node, ignoring message: %@", outerMessageNode); + //mark stanza as handled even if we don't process it further (we still received it, so we have to count it) + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + //extract inner message if mam result or carbon copy + //the original "outer" message will be kept in outerMessageNode while the forwarded stanza will be stored in messageNode + if([outerMessageNode check:@"{urn:xmpp:mam:2}result"]) //mam result + { + //wrap everything in lock instead of writing the boolean result into a temp var because incrementLastHandledStanza + //is wrapped in this lock, too (and we don't call anything else here) + @synchronized(_stateLockObject) { + if(_runningMamQueries[[outerMessageNode findFirst:@"{urn:xmpp:mam:2}result@queryid"]] == nil) + { + DDLogError(@"mam results must be asked for, ignoring this spoofed mam result having queryid: %@!", [outerMessageNode findFirst:@"{urn:xmpp:mam:2}result@queryid"]); + DDLogError(@"allowed mam queryids are: %@", _runningMamQueries); + //even these stanzas have to be counted by smacks + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + } + + //create a new XMPPMessage node instead of only a MLXMLNode because messages have some convenience properties and methods + messageNode = [[XMPPMessage alloc] initWithXMPPMessage:[outerMessageNode findFirst:@"{urn:xmpp:mam:2}result/{urn:xmpp:forward:0}forwarded/{jabber:client}message"]]; + + //move mam:2 delay timestamp into forwarded message stanza if the forwarded stanza does not have one already + //that makes parsing a lot easier later on and should not do any harm, even when resending/forwarding this inner stanza + if([outerMessageNode check:@"{urn:xmpp:mam:2}result/{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay"] && ![messageNode check:@"{urn:xmpp:delay}delay"]) + [messageNode addChildNode:[outerMessageNode findFirst:@"{urn:xmpp:mam:2}result/{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay"]]; + + DDLogDebug(@"mam extracted, messageNode is now: %@", messageNode); + } + else if([outerMessageNode check:@"{urn:xmpp:carbons:2}*"]) //carbon copy + { + if(!self.connectionProperties.usingCarbons2) + { + DDLogError(@"carbon copies not enabled, ignoring this spoofed carbon copy!"); + //even these stanzas have to be counted by smacks + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + if(![self.connectionProperties.identity.jid isEqualToString:outerMessageNode.from]) + { + DDLogError(@"carbon copies must be from our bare jid, ignoring this spoofed carbon copy!"); + //even these stanzas have to be counted by smacks + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + //create a new XMPPMessage node instead of only a MLXMLNode because messages have some convenience properties and methods + messageNode = [[XMPPMessage alloc] initWithXMPPMessage:[outerMessageNode findFirst:@"{urn:xmpp:carbons:2}*/{urn:xmpp:forward:0}forwarded/{jabber:client}message"]]; + + //move carbon copy delay timestamp into forwarded message stanza if the forwarded stanza does not have one already + //that makes parsing a lot easier later on and should not do any harm, even when resending/forwarding this inner stanza + if([outerMessageNode check:@"{urn:xmpp:delay}delay"] && ![messageNode check:@"{urn:xmpp:delay}delay"]) + [messageNode addChildNode:[outerMessageNode findFirst:@"{urn:xmpp:delay}delay"]]; + + DDLogDebug(@"carbon extracted, messageNode is now: %@", messageNode); + } + + //sanity: check if inner message from and to attributes are valid and throw it away if not + if([@"" isEqualToString:messageNode.from] || [@"" isEqualToString:messageNode.to] || [messageNode.fromHost containsString:@"@"] || [messageNode.toHost containsString:@"@"]) + { + DDLogError(@"sanity check failed for inner message node, ignoring message: %@", messageNode); + //mark stanza as handled even if we don't process it further (we still received it, so we have to count it) + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + //sanitize inner node: no from or to always means own bare jid + if(!messageNode.from) + messageNode.from = self.connectionProperties.identity.jid; + if(!messageNode.to) + messageNode.to = self.connectionProperties.identity.fullJid; + + //sanity: check if toUser or fromUser points to us and throw it away if not + if([self.connectionProperties.identity.jid isEqualToString:messageNode.toUser] == NO && [self.connectionProperties.identity.jid isEqualToString:messageNode.fromUser] == NO) + { + DDLogError(@"sanity check failed for inner message node, ignoring message: %@", messageNode); + //mark stanza as handled even if we don't process it further (we still received it, so we have to count it) + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + //assert on wrong from and to values + MLAssert(![messageNode.fromUser containsString:@"/"], @"messageNode.fromUser contains resource!", messageNode); + MLAssert(![messageNode.toUser containsString:@"/"], @"messageNode.toUser contains resource!", messageNode); + MLAssert(![outerMessageNode.fromUser containsString:@"/"], @"outerMessageNode.fromUser contains resource!", outerMessageNode); + MLAssert(![outerMessageNode.toUser containsString:@"/"], @"outerMessageNode.toUser contains resource!", outerMessageNode); + + //capture normal (non-mam-result) messages for later processing while we are doing a mam catchup (even headline messages) + //do so only while this archiveJid is listed in _inCatchup + //(of course we DON'T handle already delayed message stanzas here) + if(!delayedReplay && ( + ( + ![[messageNode findFirst:@"/@type"] isEqualToString:@"groupchat"] && + _inCatchup[self.connectionProperties.identity.jid] != nil && + ![outerMessageNode check:@"{urn:xmpp:mam:2}result"] + ) || ( + [[messageNode findFirst:@"/@type"] isEqualToString:@"groupchat"] && + _inCatchup[messageNode.fromUser] != nil && + ![outerMessageNode check:@"{urn:xmpp:mam:2}result"] + ) + )) { + DDLogInfo(@"Saving incoming message node to delayedMessageStanzas..."); + [self delayIncomingMessageStanzaUntilCatchupDone:originalParsedStanza]; + } + //only process mam results when they are *not* for priming the database with the initial stanzaid (the id will be taken from the iq result) + //we do this because we don't want to randomly add one single message to our history db after the user installs the app / adds a new account + //if the user wants to see older messages he can retrieve them using the ui (endless upscrolling through mam) + //we don't want to process messages going backwards in time, too (e.g. MLhistory:* mam queries) + else if(![outerMessageNode check:@"{urn:xmpp:mam:2}result"] || [[outerMessageNode findFirst:@"{urn:xmpp:mam:2}result@queryid"] hasPrefix:@"MLcatchup:"]) + { + DDLogInfo(@"Processing message stanza (delayedReplay=%@)...", bool2str(delayedReplay)); + + //process message + [MLMessageProcessor processMessage:messageNode andOuterMessage:outerMessageNode forAccount:self]; + + NSString* stanzaid = [outerMessageNode findFirst:@"{urn:xmpp:mam:2}result@id"]; + //extract stanza-id from message itself and check stanza-id @by according to the rules outlined in XEP-0359 + if(!stanzaid) + { + if(![messageNode check:@"/"] && [self.connectionProperties.identity.jid isEqualToString:[messageNode findFirst:@"{urn:xmpp:sid:0}stanza-id@by"]]) + stanzaid = [messageNode findFirst:@"{urn:xmpp:sid:0}stanza-id@id"]; + else if([messageNode check:@"/"] && [messageNode.fromUser isEqualToString:[messageNode findFirst:@"{urn:xmpp:sid:0}stanza-id@by"]]) + stanzaid = [messageNode findFirst:@"{urn:xmpp:sid:0}stanza-id@id"]; + } + + //handle stanzaids of groupchats differently (because groupchat messages do not enter the user's mam archive, but only the archive of the muc server) + if(stanzaid && [messageNode check:@"/"]) + { + DDLogVerbose(@"Updating lastStanzaId of muc archive %@ in database to: %@", messageNode.fromUser, stanzaid); + [[DataLayer sharedInstance] setLastStanzaId:stanzaid forMuc:messageNode.fromUser andAccount:self.accountID]; + } + else if(stanzaid && ![messageNode check:@"/"]) + { + DDLogVerbose(@"Updating lastStanzaId of user archive in database to: %@", stanzaid); + [[DataLayer sharedInstance] setLastStanzaId:stanzaid forAccount:self.accountID]; + } + } + else if([[outerMessageNode findFirst:@"{urn:xmpp:mam:2}result@queryid"] hasPrefix:@"MLhistory:"]) + [self addMessageToMamPageArray:@{@"outerMessageNode": outerMessageNode, @"messageNode": messageNode}]; //add message to mam page array to be processed later + + //only mark stanza as handled *after* processing it + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + } + else if([parsedStanza check:@"/{jabber:client}iq"]) + { + XMPPIQ* iqNode = (XMPPIQ*)parsedStanza; + + //openfire compatibility: remove iq-to of bind + if([self.connectionProperties.serverIdentity isEqualToString:@"https://www.igniterealtime.org/projects/openfire/"] && [iqNode check:@"/{jabber:client}iq/{urn:ietf:params:xml:ns:xmpp-bind}bind"]) + iqNode.to = nil; + + //sanity: check if iq from and to attributes are valid and throw it away if not + if([@"" isEqualToString:iqNode.from] || [@"" isEqualToString:iqNode.to] || [iqNode.fromHost containsString:@"@"] || [iqNode.toHost containsString:@"@"]) + { + DDLogError(@"sanity check failed for iq node, ignoring iq: %@", iqNode); + //mark stanza as handled even if we don't process it further (we still received it, so we have to count it) + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + //sanitize: no from or to always means own bare jid + if(!iqNode.from) + iqNode.from = self.connectionProperties.identity.jid; + if(!iqNode.to) + iqNode.to = self.connectionProperties.identity.fullJid; + + //sanity: check if iq id and type attributes are present and toUser points to us and throw it away if not + //use parsedStanza instead of iqNode to be sure we get the raw values even if ids etc. get added automatically to iq stanzas if accessed as XMPPIQ* object + if(![parsedStanza check:@"/@id"] || ![parsedStanza check:@"/@type"] || ![self.connectionProperties.identity.jid isEqualToString:iqNode.toUser]) + { + DDLogError(@"sanity check failed for iq node, ignoring iq: %@", iqNode); + //mark stanza as handled even if we don't process it further (we still received it, so we have to count it) + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + return; + } + + //remove handled mam queries from _runningMamQueries + if([iqNode check:@"//{urn:xmpp:mam:2}fin"] && _runningMamQueries[[iqNode findFirst:@"/@id"]] != nil) + [_runningMamQueries removeObjectForKey:[iqNode findFirst:@"/@id"]]; + else if([iqNode check:@"/"] && _runningMamQueries[[iqNode findFirst:@"/@id"]] != nil) + [_runningMamQueries removeObjectForKey:[iqNode findFirst:@"/@id"]]; + + //process registered iq handlers + NSMutableDictionary* iqHandler = nil; + @synchronized(_iqHandlers) { + iqHandler = _iqHandlers[[iqNode findFirst:@"/@id"]]; + } + if(iqHandler) + { + if(iqHandler[@"handler"] != nil) + $call(iqHandler[@"handler"], $ID(account, self), $ID(iqNode)); + else if([iqNode check:@"/"] && iqHandler[@"resultHandler"]) + ((monal_iq_handler_t) iqHandler[@"resultHandler"])(iqNode); + else if([iqNode check:@"/"] && iqHandler[@"errorHandler"]) + ((monal_iq_handler_t) iqHandler[@"errorHandler"])(iqNode); + + //remove handler after calling it + @synchronized(_iqHandlers) { + [_iqHandlers removeObjectForKey:[iqNode findFirst:@"/@id"]]; + } + } + else //only process iqs that have not already been handled by a registered iq handler + [MLIQProcessor processUnboundIq:iqNode forAccount:self]; + + //only mark stanza as handled *after* processing it + [self incrementLastHandledStanzaWithDelayedReplay:delayedReplay]; + } + else if([parsedStanza check:@"/{urn:xmpp:sm:3}enabled"]) + { + NSMutableArray* stanzas; + @synchronized(_stateLockObject) { + //save old unAckedStanzas queue before it is cleared + stanzas = self.unAckedStanzas; + + //init smacks state (this clears the unAckedStanzas queue) + [self initSM3]; + + //save streamID if resume is supported + if([[parsedStanza findFirst:@"/@resume|bool"] boolValue]) + self.streamID = [parsedStanza findFirst:@"/@id"]; + else + self.streamID = nil; + + //persist these changes (streamID and initSM3) + [self persistState]; + } + + //init session and query disco, roster etc. + [self initSession]; + + //resend unacked stanzas saved above (this happens only if the server provides smacks support without resumption support) + //or if the resumption failed for other reasons the server is responsible for + //clean up those stanzas to only include message stanzas because iqs don't survive a session change + //message duplicates are possible in this scenario, but that's better than dropping messages + [self resendUnackedMessageStanzasOnly:stanzas]; + } + else if([parsedStanza check:@"/{urn:xmpp:sm:3}resumed"] && self.connectionProperties.supportsSM3 && self.accountState sending out presence after resume"); + [self sendPresence]; + } + + //enable push in case our token has changed + [self enablePush]; + + //ping all mucs to check if we are still connected (XEP-0410) + [self.mucProcessor pingAllMucs]; + + @synchronized(_stateLockObject) { + //signal finished catchup if our current outgoing stanza counter is acked, this introduces an additional roundtrip to make sure + //all stanzas the *server* wanted to replay have been received, too + //request an ack to accomplish this if stanza replay did not already trigger one (smacksRequestInFlight is false if replay did not trigger one) + if(!self.smacksRequestInFlight) + [self requestSMAck:YES]; //force sending of the request even if the smacks queue is empty (needed to always trigger the smacks handler below after 1 RTT) + DDLogVerbose(@"Adding resume smacks handler to check for completed catchup on account %@: %@", self.accountID, self.lastOutboundStanza); + weakify(self); + [self addSmacksHandler:^{ + strongify(self); + DDLogInfo(@"Inside resume smacks handler: catchup *possibly* done (%@)", self.lastOutboundStanza); + //having no entry at all means catchup and replay are done + //if replay is not done yet, the kMonalFinishedCatchup notification will be triggered by the replay handler once the replay is finished + if(self->_inCatchup[self.connectionProperties.identity.jid] == nil && !self->_catchupDone) + { + DDLogInfo(@"Replay really done, now posting kMonalFinishedCatchup notification"); + [self handleFinishedCatchup]; + } + + //handle all delayed replays not yet done and resume them (e.g. all _inCatchup entries being NO) + NSDictionary* catchupCopy = [self->_inCatchup copy]; + for(NSString* archiveJid in catchupCopy) + { + if([catchupCopy[archiveJid] boolValue] == NO) //NO means no mam catchup running, but delayed replay not yet done --> resume delayed replay + { + DDLogInfo(@"Resuming replay of delayed stanzas for %@...", archiveJid); + //this will put a truly async block onto the receive queue which will resume the delayed stanza replay + //this replay will race with new live stanzas coming in, but that does not matter: + //every incominglive stanza will be put into our replay queue and replayed once its time comes + //the kMonalFinishedCatchup notification will be triggered by the replay handler once the replay is finished (e.g. the replay queue in our db is empty) + [self mamFinishedFor:archiveJid]; + } + } + }]; + } + + //initialize stanza counter for statistics + [self initCatchupStats]; + } + else if([parsedStanza check:@"/{urn:xmpp:sm:3}failed"] && self.connectionProperties.supportsSM3 && self.accountState=kStateBound && !self.resuming) + { + //we landed here because smacks enable failed + + self.connectionProperties.supportsSM3 = NO; + //init session and query disco, roster etc. + [self initSession]; + } +#pragma mark - SASL1 + else if([parsedStanza check:@"/{urn:ietf:params:xml:ns:xmpp-sasl}failure"]) + { + if(self.accountState >= kStateLoggedIn) + return [self invalidXMLError]; + + //record TLS version + self.connectionProperties.tlsVersion = [((MLStream*)self->_oStream) isTLS13] ? @"1.3" : @"1.2"; + + NSString* message = [parsedStanza findFirst:@"text#"];; + if([parsedStanza check:@"not-authorized"]) + { + if(!message) + message = NSLocalizedString(@"Not Authorized. Please check your credentials.", @""); + } + else + { + if(!message) + message = NSLocalizedString(@"There was a SASL error on the server.", @""); + } + message = [NSString stringWithFormat:NSLocalizedString(@"Login error, account disabled: %@", @""), message]; + + //clear pipeline cache to make sure we have a fresh restart next time + xmppPipeliningState oldPipeliningState = _pipeliningState; + _pipeliningState = kPipelinedNothing; + _cachedStreamFeaturesBeforeAuth = nil; + _cachedStreamFeaturesAfterAuth = nil; + + //don't report error but reconnect if we pipelined stuff that is not correct anymore... + if(oldPipeliningState != kPipelinedNothing) + { + DDLogWarn(@"Reconnecting to flush pipeline..."); + [self reconnect]; + } + //...but don't try again if it's really the password, that's wrong + //make sure this error is reported, even if there are other SRV records left (we disconnect here and won't try again) + else + [HelperTools postError:message withNode:nil andAccount:self andIsSevere:YES andDisableAccount:YES]; + } + else if([parsedStanza check:@"/{urn:ietf:params:xml:ns:xmpp-sasl}challenge"]) + { + //we don't support any challenge-response SASL mechanism for SASL1 + return [self invalidXMLError]; + } + else if([parsedStanza check:@"/{urn:ietf:params:xml:ns:xmpp-sasl}success"]) + { + if(self.accountState >= kStateLoggedIn) + return [self invalidXMLError]; + + //record TLS version + self.connectionProperties.tlsVersion = [((MLStream*)self->_oStream) isTLS13] ? @"1.3" : @"1.2"; + + //perform logic to handle sasl success + DDLogInfo(@"Got SASL Success"); + + self->_accountState = kStateLoggedIn; + [[MLNotificationQueue currentQueue] postNotificationName:kMLIsLoggedInNotice object:self]; + + _usableServersList = [NSMutableArray new]; //reset list to start again with the highest SRV priority on next connect + if(_loginTimer) + { + [self->_loginTimer cancel]; //we are now logged in --> cancel running login timer + _loginTimer = nil; + } + self->_loggedInOnce = YES; + + //after sasl success a new stream will be started --> reset parser to accommodate this + [self prepareXMPPParser]; + + //this could possibly be with or without XML opening (old behaviour was with opening, so keep that) + DDLogDebug(@"Sending NOT-pipelined stream restart..."); + [self startXMPPStreamWithXMLOpening:YES]; + + //only pipeline stream resume/bind if not already done + if(_pipeliningState < kPipelinedResumeOrBind) + { + //pipeline stream resume/bind after auth onto our stream header if we have cached stream features available + if(_cachedStreamFeaturesAfterAuth != nil) + { + DDLogDebug(@"Pipelining resume or bind using cached stream features: %@", _cachedStreamFeaturesAfterAuth); + _pipeliningState = kPipelinedResumeOrBind; + [self handleFeaturesAfterAuth:_cachedStreamFeaturesAfterAuth]; + } + } + } +#pragma mark - SASL2 + else if([parsedStanza check:@"/{urn:xmpp:sasl:2}challenge"]) + { + if(self.accountState >= kStateLoggedIn) + return [self invalidXMLError]; + + //only allow challenge handling, if we are in scram mode (e.g. we selected a SCRAM-XXX auth method) + if(!self->_scramHandler) + return [self invalidXMLError]; + + NSString* message = nil; + BOOL deactivate_account = NO; + NSString* innerSASLData = [[NSString alloc] initWithData:[parsedStanza findFirst:@"/{urn:xmpp:sasl:2}challenge#|base64"] encoding:NSUTF8StringEncoding]; + switch([self->_scramHandler parseServerFirstMessage:innerSASLData]) { + case MLScramStatusSSDPTriggered: deactivate_account = YES; message = NSLocalizedString(@"Detected ongoing MITM attack via SSDP, aborting authentication and disabling account to limit damage. You should try to reenable your account once you are in a clean networking environment again.", @""); break; + case MLScramStatusNonceError: deactivate_account = NO; message = NSLocalizedString(@"Error handling SASL challenge of server (nonce error), disconnecting!", @"parenthesis should be verbatim"); break; + case MLScramStatusUnsupportedMAttribute: deactivate_account = NO; message = NSLocalizedString(@"Error handling SASL challenge of server (m-attr error), disconnecting!", @"parenthesis should be verbatim"); break; + case MLScramStatusIterationCountInsecure: deactivate_account = NO; message = NSLocalizedString(@"Error handling SASL challenge of server (iteration count too low), disconnecting!", @"parenthesis should be verbatim"); break; + case MLScramStatusServerFirstOK: deactivate_account = NO; message = nil; break; //everything is okay + default: unreachable(@"wrong status for scram message!"); break; + } + + //check for incomplete XEP-0440 support (not implementing mandatory tls-server-end-point channel-binding) not mitigated by SSDP + //(we allow either support for tls-server-end-point or SSDP signed non-support) + if([kServerDoesNotFollowXep0440Error isEqualToString:[self channelBindingToUse]]) + { + MLXMLNode* streamError = [[MLXMLNode alloc] initWithElement:@"stream:error" withAttributes:@{@"type": @"cancel"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"undefined-condition" andNamespace:@"urn:ietf:params:xml:ns:xmpp-streams" withAttributes:@{} andChildren:@[] andData:nil], + [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-streams" withAttributes:@{} andChildren:@[] andData:kServerDoesNotFollowXep0440Error], + ] andData:nil]; + [self disconnectWithStreamError:streamError andExplicitLogout:YES]; + + //make sure this error is reported, even if there are other SRV records left (we disconnect here and won't try again) + [HelperTools postError:NSLocalizedString(@"Either this is a man-in-the-middle attack OR your server neither implements XEP-0474 nor does it fully implement XEP-0440 which mandates support for tls-server-end-point channel-binding. In either case you should inform your server admin! Account disabled now.", @"") withNode:nil andAccount:self andIsSevere:YES andDisableAccount:YES]; + } + + if(message != nil) + { + DDLogError(@"SCRAM says this server-first message was wrong!"); + + //clear pipeline cache to make sure we have a fresh restart next time + xmppPipeliningState oldPipeliningState = _pipeliningState; + _pipeliningState = kPipelinedNothing; + _cachedStreamFeaturesBeforeAuth = nil; + _cachedStreamFeaturesAfterAuth = nil; + + //don't report error but reconnect if we pipelined stuff that is not correct anymore... + if(oldPipeliningState != kPipelinedNothing) + { + DDLogWarn(@"Reconnecting to flush pipeline..."); + [self reconnect]; + return; + } + + //...but don't try again if it's really the server-first message, that's wrong + //make sure this error is reported, even if there are other SRV records left (we disconnect here and won't try again) + //deactivate the account if requested, too + [HelperTools postError:message withNode:nil andAccount:self andIsSevere:YES andDisableAccount:deactivate_account]; + [self disconnect]; + + return; + } + + NSData* channelBindingData = [((MLStream*)self->_oStream) channelBindingDataForType:[self channelBindingToUse]]; + MLXMLNode* responseXML = [[MLXMLNode alloc] initWithElement:@"response" andNamespace:@"urn:xmpp:sasl:2" withAttributes:@{} andChildren:@[] andData:[HelperTools encodeBase64WithString:[self->_scramHandler clientFinalMessageWithChannelBindingData:channelBindingData]]]; + [self send:responseXML]; + + //pipeline stream restart + /* + * WARNING: this can not be done, because pipelining a stream restart will break the local parser: + * 1. the "tag" confuses the parser if its coming in an already established stream (e.g. if it sees it twice) + * 2. the parser can not be reset after receiving the sasl because the old parser could have already swallowed everything + * coming after the (e.g. the new stream opening and stream features and possibly even the smacks resumption data) + * 3. making the used parser (NSXMLParser) ignore subsequent headers does not seem possible + * 4. switching to a new parser (maybe written in rust) can solve this and would save us 1 RTT more in every sasl scheme (even challenge-response ones) + * TODO SOLUTION: SASL2 supports the element instead, to "pipeline" smacks-resume and/or bind2 onto the SASL2 authentication + + DDLogDebug(@"Pipelining stream restart after response to auth challenge..."); + _pipeliningState = kPipelinedStreamRestart; + [self startXMPPStreamWithXMLOpening:NO]; + + //pipeline stream resume/bind after auth onto our stream header if we have cached stream features available + if(_cachedStreamFeaturesAfterAuth != nil) + { + DDLogDebug(@"Pipelining resume or bind using cached stream features: %@", _cachedStreamFeaturesAfterAuth); + _pipeliningState = kPipelinedResumeOrBind; + [self handleFeaturesAfterAuth:_cachedStreamFeaturesAfterAuth]; + } + */ + } + else if([parsedStanza check:@"/{urn:xmpp:sasl:2}failure"]) + { + NSString* errorReason = [parsedStanza findFirst:@"{urn:ietf:params:xml:ns:xmpp-streams}!text$"]; + NSString* message = [parsedStanza findFirst:@"text#"]; + DDLogWarn(@"Got SASL2 %@: %@", errorReason, message); + if([errorReason isEqualToString:@"not-authorized"]) + { + if(!message) + message = NSLocalizedString(@"Not Authorized. Please check your credentials.", @""); + } + else + { + if(!message) + message = [NSString stringWithFormat:NSLocalizedString(@"Server returned SASL2 error '%@'.", @""), errorReason]; + } + message = [NSString stringWithFormat:NSLocalizedString(@"Login error, account disabled: %@", @""), message]; + + //clear pipeline cache to make sure we have a fresh restart next time + xmppPipeliningState oldPipeliningState = _pipeliningState; + _pipeliningState = kPipelinedNothing; + _cachedStreamFeaturesBeforeAuth = nil; + _cachedStreamFeaturesAfterAuth = nil; + + //don't report error but reconnect if we pipelined stuff that is not correct anymore... + if(oldPipeliningState != kPipelinedNothing) + { + DDLogWarn(@"Reconnecting to flush pipeline..."); + [self reconnect]; + } + //...but don't try again if it's really the password, that's wrong + else + { + //display sasl mechanism list and list of channel-binding types even if SASL2 failed + + //build mechanism list displayed in ui (mark _scramHandler.method as used) + NSMutableDictionary* mechanismList = [NSMutableDictionary new]; + for(NSString* mechanism in _supportedSaslMechanisms) + mechanismList[mechanism] = @([mechanism isEqualToString:self->_scramHandler.method]); + DDLogInfo(@"Saving saslMethods list: %@", mechanismList); + self.connectionProperties.saslMethods = mechanismList; + + //build channel-binding list displayed in ui (mark [self channelBindingToUse] as used) + NSMutableDictionary* channelBindings = [NSMutableDictionary new]; + if(_supportedChannelBindings != nil) + for(NSString* cbType in _supportedChannelBindings) + channelBindings[cbType] = @([cbType isEqualToString:[self channelBindingToUse]]); + DDLogInfo(@"Saving channel-binding types list: %@", channelBindings); + self.connectionProperties.channelBindingTypes = channelBindings; + + //record SDDP support + self.connectionProperties.supportsSSDP = self->_scramHandler.ssdpSupported; + + //record TLS version + self.connectionProperties.tlsVersion = [((MLStream*)self->_oStream) isTLS13] ? @"1.3" : @"1.2"; + + //make sure this error is reported, even if there are other SRV records left (we disconnect here and won't try again) + [HelperTools postError:message withNode:nil andAccount:self andIsSevere:YES andDisableAccount:YES]; + } + } + else if([parsedStanza check:@"/{urn:xmpp:sasl:2}success"]) + { + if(self.accountState >= kStateLoggedIn) + return [self invalidXMLError]; + + //check server-final message for correctness if needed + if(!self->_scramHandler.finishedSuccessfully) + [self handleScramInSuccessOrContinue:parsedStanza]; + + //build mechanism list displayed in ui (mark _scramHandler.method as used) + NSMutableDictionary* mechanismList = [NSMutableDictionary new]; + for(NSString* mechanism in _supportedSaslMechanisms) + mechanismList[mechanism] = @([mechanism isEqualToString:self->_scramHandler.method]); + DDLogInfo(@"Saving saslMethods list: %@", mechanismList); + self.connectionProperties.saslMethods = mechanismList; + + //build channel-binding list displayed in ui (mark [self channelBindingToUse] as used) + NSMutableDictionary* channelBindings = [NSMutableDictionary new]; + if(_supportedChannelBindings != nil) + for(NSString* cbType in _supportedChannelBindings) + channelBindings[cbType] = @([cbType isEqualToString:[self channelBindingToUse]]); + DDLogInfo(@"Saving channel-binding types list: %@", channelBindings); + self.connectionProperties.channelBindingTypes = channelBindings; + + //update user identity using authorization-identifier, including support for fullJids (as specified by BIND2) + [self.connectionProperties.identity bindJid:[parsedStanza findFirst:@"authorization-identifier#"]]; + + //record SDDP support + self.connectionProperties.supportsSSDP = self->_scramHandler.ssdpSupported; + + //record TLS version + self.connectionProperties.tlsVersion = [((MLStream*)self->_oStream) isTLS13] ? @"1.3" : @"1.2"; + + self->_scramHandler = nil; + self->_blockToCallOnTCPOpen = nil; //just to be sure but not strictly necessary + self->_accountState = kStateLoggedIn; + _usableServersList = [NSMutableArray new]; //reset list to start again with the highest SRV priority on next connect + if(_loginTimer) + { + [self->_loginTimer cancel]; //we are now logged in --> cancel running login timer + _loginTimer = nil; + } + self->_loggedInOnce = YES; + + //pin sasl2 support for this account (this is done only after successful auth to prevent DOS MITM attacks simulating SASL2 support) + //downgrading to SASL1 would mean PLAIN instead of SCRAM and no protocol agility for channel-bindings, + //if XEP-0440 is not supported by server + [[DataLayer sharedInstance] deactivatePlainForAccount:self.accountID]; + + //NOTE: we don't have any stream restart when using SASL2 + //NOTE: we don't need to pipeline anything here, because SASL2 sends out the new stream features immediately without a stream restart + _cachedStreamFeaturesAfterAuth = nil; //make sure we don't accidentally try to do pipelining + } + else if([parsedStanza check:@"/{urn:xmpp:sasl:2}continue"]) + { + if(self.accountState >= kStateLoggedIn) + return [self invalidXMLError]; + + //check server-final message for correctness + [self handleScramInSuccessOrContinue:parsedStanza]; + + NSArray* tasks = [parsedStanza find:@"tasks/task#"]; + if(tasks.count == 0) + { + [HelperTools postError:NSLocalizedString(@"Server implementation error: SASL2 tasks empty, account disabled!", @"") withNode:nil andAccount:self andIsSevere:YES andDisableAccount:YES]; + return; + } + + if(tasks.count != 1) + { + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"We don't support any task requested by the server, account disabled: %@", @""), tasks] withNode:nil andAccount:self andIsSevere:YES andDisableAccount:YES]; + return; + } + + if(![tasks[0] isEqualToString:_upgradeTask]) + { + [HelperTools postError:[NSString stringWithFormat:NSLocalizedString(@"We don't support the single task requested by the server, account disabled: %@", @""), tasks] withNode:nil andAccount:self andIsSevere:YES andDisableAccount:YES]; + return; + } + + [self send:[[MLXMLNode alloc] initWithElement:@"next" andNamespace:@"urn:xmpp:sasl:2" withAttributes:@{ + @"task": _upgradeTask + } andChildren:@[] andData:nil]]; + } + else if([parsedStanza check:@"/{urn:xmpp:sasl:2}task-data/{urn:xmpp:scram-upgrade:0}salt"]) + { + NSData* salt = [parsedStanza findFirst:@"{urn:xmpp:scram-upgrade:0}salt#|base64"]; + uint32_t iterations = (uint32_t)[[parsedStanza findFirst:@"{urn:xmpp:scram-upgrade:0}salt@iterations|uint"] unsignedLongValue]; + + NSString* scramMechanism = [_upgradeTask substringWithRange:NSMakeRange(5, _upgradeTask.length-5)]; + DDLogInfo(@"Upgrading password using SCRAM mechanism: %@", scramMechanism); + SCRAM* scramUpgradeHandler = [[SCRAM alloc] initWithUsername:self.connectionProperties.identity.user password:self.connectionProperties.identity.password andMethod:scramMechanism]; + NSData* saltedPassword = [scramUpgradeHandler hashPasswordWithSalt:salt andIterationCount:iterations]; + + [self send:[[MLXMLNode alloc] initWithElement:@"task-data" andNamespace:@"urn:xmpp:sasl:2" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"hash" andNamespace:@"urn:xmpp:scram-upgrade:0" andData:[HelperTools encodeBase64WithData:saltedPassword]] + ] andData:nil]]; + } + else if([parsedStanza check:@"/{http://etherx.jabber.org/streams}features"]) + { + //prevent reconnect attempt + if(_accountState < kStateHasStream) + _accountState = kStateHasStream; + + //perform logic to handle stream + if(self.accountState < kStateLoggedIn) + { + //handle features normally if we didn't have a cached copy for pipelining (but always refresh our cached copy) + if(_cachedStreamFeaturesBeforeAuth == nil) + { + DDLogDebug(@"Handling NOT-pipelined stream features (before auth)..."); + [self handleFeaturesBeforeAuth:parsedStanza]; + } + else + DDLogDebug(@"Stream features (before auth) already read from cache, ignoring incoming stream features (but refreshing cache)..."); + _cachedStreamFeaturesBeforeAuth = parsedStanza; + } + else + { + //handle features normally if we didn't have a cached copy for pipelining (but always refresh our cached copy) + if(_cachedStreamFeaturesAfterAuth == nil) + { + DDLogDebug(@"Handling NOT-pipelined stream features (after auth)..."); + [self handleFeaturesAfterAuth:parsedStanza]; + } + else + DDLogDebug(@"Stream features (after auth) already read from cache, ignoring incoming stream features (but refreshing cache).\n Cached: %@\nIncoming: %@", _cachedStreamFeaturesAfterAuth, parsedStanza); + _cachedStreamFeaturesAfterAuth = parsedStanza; + } + } + else if([parsedStanza check:@"/{http://etherx.jabber.org/streams}error"]) + { + NSString* errorReason = [parsedStanza findFirst:@"{urn:ietf:params:xml:ns:xmpp-streams}!text$"]; + NSString* errorText = [parsedStanza findFirst:@"{urn:ietf:params:xml:ns:xmpp-streams}text#"]; + DDLogWarn(@"Got secure XMPP stream error %@: %@", errorReason, errorText); + DDLogDebug(@"Setting _pipeliningState to kPipelinedNothing and clearing _cachedStreamFeaturesBeforeAuth and _cachedStreamFeaturesAfterAuth..."); + _pipeliningState = kPipelinedNothing; + _cachedStreamFeaturesBeforeAuth = nil; + _cachedStreamFeaturesAfterAuth = nil; + NSString* message = [NSString stringWithFormat:NSLocalizedString(@"XMPP stream error: %@", @""), errorReason]; + if(errorText && ![errorText isEqualToString:@""]) + message = [NSString stringWithFormat:NSLocalizedString(@"XMPP stream error %@: %@", @""), errorReason, errorText]; + [self postError:message withIsSevere:NO]; + [self reconnect]; + } + else + { + DDLogWarn(@"Ignoring unhandled top-level xml element <%@>: %@", parsedStanza.element, parsedStanza); + } + } + //handle only a subset of stanzas/nonzas when in insecure (non-tls) context + else + { + if([parsedStanza check:@"/{http://etherx.jabber.org/streams}error"]) + { + NSString* errorReason = [parsedStanza findFirst:@"{urn:ietf:params:xml:ns:xmpp-streams}!text$"]; + NSString* errorText = [parsedStanza findFirst:@"{urn:ietf:params:xml:ns:xmpp-streams}text#"]; + DDLogWarn(@"Got *INSECURE* XMPP stream error %@: %@", errorReason, errorText); + + NSString* message = [NSString stringWithFormat:NSLocalizedString(@"XMPP stream error: %@", @""), errorReason]; + if(errorText && ![errorText isEqualToString:@""]) + message = [NSString stringWithFormat:NSLocalizedString(@"XMPP stream error %@: %@", @""), errorReason, errorText]; + + //don't ignore this error when trying to register, even though it could be a mitm etc. + if(_registration || _registrationSubmission) + [self postError:message withIsSevere:NO]; + else + { +//this error could be a mitm or some other network problem caused by an active attacker, just ignore it since we are not in a tls context here +#ifdef IS_ALPHA + [self postError:message withIsSevere:NO]; +#endif + } + [self reconnect]; + } + else if([parsedStanza check:@"/{http://etherx.jabber.org/streams}features"]) + { + //normally we would ignore starttls stream feature presence and opportunistically try starttls + //(this is in accordance to RFC 7590: https://tools.ietf.org/html/rfc7590#section-3.1 ) + //BUT: we already pipelined the starttls command when starting the stream --> do nothing here + DDLogInfo(@"Ignoring non-encrypted stream features (we already pipelined the starttls command when opening the stream)"); + return; + } + else if([parsedStanza check:@"/{urn:ietf:params:xml:ns:xmpp-tls}proceed"]) + { + //stop the old xml parser and clear the parse queue + //if we do not do this we could be prone to mitm attacks injecting xml elements into the stream before it gets encrypted + //such xml elements would then get processed as received *after* the TLS initialization + if(_xmlParser!=nil) + { + DDLogInfo(@"stopping old xml parser"); + [_xmlParser setDelegate:nil]; + [_xmlParser abortParsing]; + _xmlParser = nil; + //throw away all parsed but not processed stanzas (we aborted the parser right now) + //the xml parser will fill the parse queue synchronously while < kStateBound + //--> no stanzas/nonzas will leak into the parse queue after resetting the parser and clearing the parse queue + [_parseQueue cancelAllOperations]; + } + //prepare input/output streams + [_iPipe drainInputStreamAndCloseOutputStream]; //remove all pending data before starting tls handshake + self->_streamHasSpace = NO; //make sure we do not try to send any data while the tls handshake is still performed + + //dispatch async to not block the db transaction of the proceed stanza inside the receive queue + //while waiting for the tls handshake to complete + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + DDLogInfo(@"configuring/starting tls handshake"); + MLStream* oStream = (MLStream*)self->_oStream; + [oStream startTLS]; + if(!oStream.hasTLS) + { + //only show this error if the connection was not closed but timed out (this is the case we want to debug here) + //other cases (cert errors etc.) should not trigger this notification + if([oStream streamStatus] != NSStreamStatusClosed) + showErrorOnAlpha(self, @"Failed to complete TLS handshake while using STARTTLS, retrying!"); + DDLogError(@"Failed to complete TLS handshake, reconnecting!"); + [self reconnect]; + return; + } + self->_startTLSComplete = YES; + + //we successfully completed the tls handshake, now proceed inside the receive queue again + [self->_receiveQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + if(self.accountState_cachedStreamFeaturesBeforeAuth != nil) + { + DDLogDebug(@"Pipelining auth using cached stream features: %@", self->_cachedStreamFeaturesBeforeAuth); + self->_pipeliningState = kPipelinedAuth; + [self handleFeaturesBeforeAuth:self->_cachedStreamFeaturesBeforeAuth]; + } + }]; + [self unfreezeSendQueue]; //this will flush all stanzas added inside the db transaction and now waiting in the send queue + } onQueue:@"receiveQueue"]; + [self persistState]; //make sure to persist all state changes triggered by the events in the notification queue + }]] waitUntilFinished:NO]; + }); + } + else + { + DDLogError(@"Ignoring unhandled *INSECURE* top-level xml element <%@>, reconnecting: %@", parsedStanza.element, parsedStanza); + [self reconnect]; + } + } +} + +-(void) handleFeaturesBeforeAuth:(MLXMLNode*) parsedStanza +{ + return [self handleFeaturesBeforeAuth:parsedStanza withForceSasl2:NO]; +} + +-(void) handleFeaturesBeforeAuth:(MLXMLNode*) parsedStanza withForceSasl2:(BOOL) forceSasl2 +{ + monal_id_returning_void_block_t checkProperSasl2Support = ^{ + //check if we SASL2 is supported with something better than PLAIN and, if so, switch off plain_activated + NSSet* supportedSasl2Mechanisms = [NSSet setWithArray:[parsedStanza find:@"{urn:xmpp:sasl:2}authentication/mechanism#"]]; + for(NSString* mechanism in [SCRAM supportedMechanismsIncludingChannelBinding:YES]) + if([supportedSasl2Mechanisms containsObject:mechanism]) + { + return @YES; + } + return @NO; + }; + monal_id_block_t clearPipelineCacheOrReportSevereError = ^(NSString* msg) { + DDLogWarn(@"Clearing auth pipeline due to error..."); + + //clear pipeline cache to make sure we have a fresh restart next time + xmppPipeliningState oldPipeliningState = self->_pipeliningState; + self->_pipeliningState = kPipelinedNothing; + self->_cachedStreamFeaturesBeforeAuth = nil; + self->_cachedStreamFeaturesAfterAuth = nil; + + if(oldPipeliningState != kPipelinedNothing) + { + DDLogWarn(@"Retrying auth without pipelining..."); + [self reconnect]; + } + else + { + //make sure this error is reported, even if there are other SRV records left (we disconnect here and won't try again) + [HelperTools postError:msg withNode:nil andAccount:self andIsSevere:YES andDisableAccount:YES]; + } + }; + //called below, if neither SASL1 nor SASL2 could be used to negotiate a valid SASL mechanism + monal_void_block_t noAuthSupported = ^{ + DDLogWarn(@"No supported auth mechanism: %@", self->_supportedSaslMechanisms); + + //sasl2 will be pinned if we saw sasl2 support and PLAIN was NOT allowed by creating this account using the advanced account creation menu + //display scary warning message if sasl2 is pinned and login was successful at least once + //or display a message pointing to the advanced account creation menu if sasl2 is pinned and login was NOT successful at least once + //(e.g. we are trying to create this account just now) + if(![[DataLayer sharedInstance] isPlainActivatedForAccount:self.accountID]) + { + DDLogDebug(@"Plain is not activated for this account..."); + if(self->_loggedInOnce) + { + clearPipelineCacheOrReportSevereError(NSLocalizedString(@"Server suddenly lacks support for SASL2-SCRAM, ongoing MITM attack highly likely, aborting authentication and disabling account to limit damage. You should try to reenable your account once you are in a clean networking environment again.", @"")); + return; + } + //_supportedSaslMechanisms==nil indicates SASL1 support only + else if([self->_supportedSaslMechanisms containsObject:@"PLAIN"] || self->_supportedSaslMechanisms == nil) + { + //leave that in for translators, we might use it at a later time + while(!NSLocalizedString(@"This server isn't additionally hardened against man-in-the-middle attacks on the TLS encryption layer by using authentication methods that are secure against such attacks! This indicates an ongoing attack if the server is supposed to support SASL2 and SCRAM and is harmless otherwise. Use the advanced account creation menu and turn on the PLAIN switch there if you still want to log in to this server.", @"")); + + clearPipelineCacheOrReportSevereError(NSLocalizedString(@"This server lacks support for SASL2 and SCRAM, additionally hardening authentication against man-in-the-middle attacks on the TLS encryption layer. Since this server is listed as supporting both at https://github.com/monal-im/SCRAM_PreloadList (or you intentionally left the PLAIN switch off when using the advanced account creation menu), an ongoing MITM attack is very likely! Try again once you are in a clean network environment.", @"")); + return; + } + } + clearPipelineCacheOrReportSevereError(NSLocalizedString(@"No supported auth mechanism found, disabling account!", @"")); + }; + + if(![parsedStanza check:@"{urn:xmpp:ibr-token:0}register"]) + DDLogWarn(@"Server NOT supporting Pre-Authenticated IBR"); + if(_registration) + { + if(_registrationToken && [parsedStanza check:@"{urn:xmpp:ibr-token:0}register"]) + { + DDLogInfo(@"Registration: Calling submitRegToken"); + [self submitRegToken:_registrationToken]; + } + else + { + DDLogInfo(@"Registration: Directly calling requestRegForm"); + [self requestRegForm]; + } + } + else if(_registrationSubmission) + { + DDLogInfo(@"Registration: Calling submitRegForm"); + [self submitRegForm]; + } + //prefer SASL2 over SASL1 + else if([parsedStanza check:@"{urn:xmpp:sasl:2}authentication/mechanism"] && (![[DataLayer sharedInstance] isPlainActivatedForAccount:self.accountID] || forceSasl2)) + { + DDLogDebug(@"Trying SASL2..."); + + weakify(self); + _blockToCallOnTCPOpen = ^{ + strongify(self); + + if([self->_supportedSaslMechanisms containsObject:@"PLAIN"]) + DDLogWarn(@"Server supports SASL2 PLAIN, ignoring because this is insecure!"); + + //create list of upgradable scram mechanisms and pick the first one (highest security) the server and we support + //but only do so, if we are using channel-binding for additional security + //(a MITM could passively intercept the new SCRAM hash which is roughly equivalent to intercepting the plaintext password) + self->_upgradeTask = nil; + if([self channelBindingToUse] != nil && ![kServerDoesNotFollowXep0440Error isEqualToString:[self channelBindingToUse]]) + { + NSSet* upgradesOffered = [NSSet setWithArray:[parsedStanza find:@"{urn:xmpp:sasl:2}authentication/{urn:xmpp:sasl:upgrade:0}upgrade#"]]; + for(NSString* method in [SCRAM supportedMechanismsIncludingChannelBinding:NO]) + if([upgradesOffered containsObject:[NSString stringWithFormat:@"UPGR-%@", method]]) + { + self->_upgradeTask = [NSString stringWithFormat:@"UPGR-%@", method]; + break; + } + } + + //check for supported scram mechanisms (highest security first!) + for(NSString* mechanism in [SCRAM supportedMechanismsIncludingChannelBinding:[self channelBindingToUse] != nil]) + if([self->_supportedSaslMechanisms containsObject:mechanism]) + { + self->_scramHandler = [[SCRAM alloc] initWithUsername:self.connectionProperties.identity.user password:self.connectionProperties.identity.password andMethod:mechanism]; + //set ssdp data for downgrade protection + //_supportedChannelBindings will be nil, if XEP-0440 is not supported by our server (which should never happen because XEP-0440 is mandatory for SASL2) + [self->_scramHandler setSSDPMechanisms:[self->_supportedSaslMechanisms allObjects] andChannelBindingTypes:[self->_supportedChannelBindings allObjects]]; + MLXMLNode* authenticate = [[MLXMLNode alloc] + initWithElement:@"authenticate" + andNamespace:@"urn:xmpp:sasl:2" + withAttributes:@{@"mechanism": mechanism} + andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"initial-response" andData:[HelperTools encodeBase64WithString:[self->_scramHandler clientFirstMessageWithChannelBinding:[self channelBindingToUse]]]], + [[MLXMLNode alloc] initWithElement:@"user-agent" withAttributes:@{ + @"id":[[[UIDevice currentDevice] identifierForVendor] UUIDString], + } andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"software" andData:@"Monal IM"], + [[MLXMLNode alloc] initWithElement:@"device" andData:[[UIDevice currentDevice] name]], + ] andData:nil], + ] + andData:nil + ]; + //add upgrade element if we mutually support upgrades + if(self->_upgradeTask != nil) + [authenticate addChildNode:[[MLXMLNode alloc] initWithElement:@"upgrade" andNamespace:@"urn:xmpp:sasl:upgrade:0" andData:self->_upgradeTask]]; + [self send:authenticate]; + return; + } + + //could not find any matching SASL2 mechanism (we do NOT support PLAIN) + noAuthSupported(); + }; + + //extract menchanisms presented + _supportedSaslMechanisms = [NSSet setWithArray:[parsedStanza find:@"{urn:xmpp:sasl:2}authentication/mechanism#"]]; + + //extract supported channel-binding types + if([parsedStanza check:@"{urn:xmpp:sasl-cb:0}sasl-channel-binding"]) + _supportedChannelBindings = [NSSet setWithArray:[parsedStanza find:@"{urn:xmpp:sasl-cb:0}sasl-channel-binding/channel-binding@type"]]; + else + _supportedChannelBindings = nil; + + //check if the server supports *any* scram method and wait for TLS connection establishment if so + BOOL supportsScram = NO; + for(NSString* mechanism in [SCRAM supportedMechanismsIncludingChannelBinding:YES]) + if([_supportedSaslMechanisms containsObject:mechanism]) + supportsScram = YES; + + //directly call our continuation block if SCRAM is not supported, because _blockToCallOnTCPOpen() will throw an error then + //(we currently only support SCRAM for SASL2) + //pipelining can also be done immediately if we are sure the tls handshake is complete (e.g. we're NOT in direct tls mode) + //and if we are not pipelining the auth, we can call the block immediately, too + //(because the TLS connection was obviously already established and that made us receive the non-cached stream features used here) + //if we don't call it here, the continuation block will be called automatically once the TLS connection got established + if(!supportsScram || !self.connectionProperties.server.isDirectTLS || _pipeliningState < kPipelinedAuth) + { + _blockToCallOnTCPOpen(); + _blockToCallOnTCPOpen = nil; //don't call this twice + } + else + DDLogWarn(@"Waiting until TLS stream is connected before pipelining the auth element due to channel binding..."); + } + //check if the server activated SASL2 after previously only upporting SASL1 + else if([[DataLayer sharedInstance] isPlainActivatedForAccount:self.accountID] && ((NSNumber*)checkProperSasl2Support()).boolValue) + { + DDLogInfo(@"We detected SASL2 SCRAM support, deactivating forced SASL1 PLAIN fallback and retrying using SASL2..."); + [[DataLayer sharedInstance] deactivatePlainForAccount:self.accountID]; + //try again, this time using sasl2 + return [self handleFeaturesBeforeAuth:parsedStanza withForceSasl2:YES]; + } + //SASL1 is fallback only if SASL2 isn't supported with something better than PLAIN + else if([parsedStanza check:@"{urn:ietf:params:xml:ns:xmpp-sasl}mechanisms/mechanism"] && [[DataLayer sharedInstance] isPlainActivatedForAccount:self.accountID]) + { + DDLogDebug(@"Trying SASL1..."); + + //extract menchanisms presented + NSSet* supportedSaslMechanisms = [NSSet setWithArray:[parsedStanza find:@"{urn:ietf:params:xml:ns:xmpp-sasl}mechanisms/mechanism#"]]; + + if([supportedSaslMechanisms containsObject:@"PLAIN"]) + { + [self send:[[MLXMLNode alloc] + initWithElement:@"auth" + andNamespace:@"urn:ietf:params:xml:ns:xmpp-sasl" + withAttributes:@{@"mechanism": @"PLAIN"} + andChildren:@[] + andData:[HelperTools encodeBase64WithString: [NSString stringWithFormat:@"\0%@\0%@", self.connectionProperties.identity.user, self.connectionProperties.identity.password]] + ]]; + + //even double pipelining (e.g. pipelining onto the already pipelined sasl plain auth) is possible when using auth=PLAIN + /* + * WARNING: this can not be done, because pipelining a stream restart will break the local parser: + * 1. the "tag" confuses the parser if its coming in an already established stream (e.g. if it sees it twice) + * 2. the parser can not be reset after receiving the sasl because the old parser could have already swallowed everything + * coming after the (e.g. the new stream opening and stream features and possibly even the smacks resumption data) + * 3. making the used parser (NSXMLParser) ignore subsequent headers does not seem possible + * 4. switching to a new parser (maybe written in rust) can solve this and would save us 1 RTT more in every sasl scheme (even challenge-response ones) + * TODO SOLUTION: SASL2 supports the element instead, to "pipeline" smacks-resume and/or bind2 onto the SASL2 authentication + DDLogDebug(@"Pipelining stream restart after auth..."); + _pipeliningState = kPipelinedStreamRestart; + [self startXMPPStreamWithXMLOpening:NO]; + + //pipeline stream resume/bind after auth onto our stream header if we have cached stream features available + if(_cachedStreamFeaturesAfterAuth != nil) + { + DDLogDebug(@"Pipelining resume or bind using cached stream features: %@", _cachedStreamFeaturesAfterAuth); + _pipeliningState = kPipelinedResumeOrBind; + [self handleFeaturesAfterAuth:_cachedStreamFeaturesAfterAuth]; + } + */ + } + else + noAuthSupported(); + } + else + { + DDLogDebug(@"Neither SASL2 nor SASL1 worked..."); + + //this is not a downgrade but something weird going on, log it as such + if(![parsedStanza check:@"{urn:xmpp:sasl:2}authentication/mechanism"] && ![parsedStanza check:@"{urn:ietf:params:xml:ns:xmpp-sasl}mechanisms/mechanism"]) + DDLogError(@"Something weird happened: neither SASL1 nor SASL2 auth supported by this server!"); + noAuthSupported(); + } +} + +-(void) handleFeaturesAfterAuth:(MLXMLNode*) parsedStanza +{ + self.connectionProperties.serverFeatures = parsedStanza; + + //this is set to NO if we fail to enable it + if([parsedStanza check:@"{urn:xmpp:sm:3}sm"]) + { + DDLogInfo(@"Server supports SM3"); + self.connectionProperties.supportsSM3 = YES; + } + + if([parsedStanza check:@"{http://jabber.org/protocol/caps}c@node"]) + { + DDLogInfo(@"Server identity: %@", [parsedStanza findFirst:@"{http://jabber.org/protocol/caps}c@node"]); + self.connectionProperties.serverIdentity = [parsedStanza findFirst:@"{http://jabber.org/protocol/caps}c@node"]; + } + + MLXMLNode* resumeNode = nil; + @synchronized(_stateLockObject) { + //test if smacks is supported and allows resume + if(self.connectionProperties.supportsSM3 && self.streamID) + { + NSDictionary* dic = @{ + @"h":[NSString stringWithFormat:@"%@",self.lastHandledInboundStanza], + @"previd":self.streamID, + }; + resumeNode = [[MLXMLNode alloc] initWithElement:@"resume" andNamespace:@"urn:xmpp:sm:3" withAttributes:dic andChildren:@[] andData:nil]; + self.resuming = YES; //this is needed to distinguish a failed smacks resume and a failed smacks enable later on + } + } + if(resumeNode) + [self send:resumeNode]; + else + [self bindResource:self.connectionProperties.identity.resource]; +} + +-(void) handleScramInSuccessOrContinue:(MLXMLNode*) parsedStanza +{ + //perform logic to handle sasl success + DDLogInfo(@"Got SASL2 Success/Continue"); + + //only parse and validate scram response, if we are in scram mode (should always be the case) + MLAssert(self->_scramHandler != nil, @"self->_scramHandler should NEVER be nil when using SASL2!"); + + NSString* message = nil; + BOOL deactivate_account = NO; + NSString* innerSASLData = [[NSString alloc] initWithData:[parsedStanza findFirst:@"additional-data#|base64"] encoding:NSUTF8StringEncoding]; + switch([self->_scramHandler parseServerFinalMessage:innerSASLData]) { + case MLScramStatusWrongServerProof: deactivate_account = YES; message = NSLocalizedString(@"SCRAM server proof wrong, ongoing MITM attack highly likely, aborting authentication and disabling account to limit damage. You should try to reenable your account once you are in a clean networking environment again.", @""); break; + case MLScramStatusServerError: deactivate_account = NO; message = NSLocalizedString(@"Unexpected error authenticating server using SASL2 (does your server have a bug?), disconnecting!", @""); break; + case MLScramStatusServerFinalOK: deactivate_account = NO; message = nil; break; //everything is okay + default: unreachable(@"wrong status for scram message!"); break; + } + + if(message != nil) + { + DDLogError(@"SCRAM says this server-final message was wrong!"); + + //clear pipeline cache to make sure we have a fresh restart next time + _pipeliningState = kPipelinedNothing; + _cachedStreamFeaturesBeforeAuth = nil; + _cachedStreamFeaturesAfterAuth = nil; + + //make sure this error is reported, even if there are other SRV records left (we disconnect here and won't try again) + //deactivate the account if requested, too + [HelperTools postError:message withNode:nil andAccount:self andIsSevere:YES andDisableAccount:deactivate_account]; + [self disconnect]; + + return; + } + else + DDLogDebug(@"SCRAM says this server-final message was correct"); +} + +//bridge needed fo MLServerDetails.m +-(NSArray*) supportedChannelBindingTypes +{ + return [((MLStream*)self->_oStream) supportedChannelBindingTypes]; +} + +-(NSString* _Nullable) channelBindingToUse +{ + NSArray* typesList = [((MLStream*)self->_oStream) supportedChannelBindingTypes]; + if(typesList == nil || typesList.count == 0) + return nil; //we don't support any channel-binding for this TLS connection + for(NSString* type in typesList) + if(_supportedChannelBindings != nil && [_supportedChannelBindings containsObject:type]) + return type; + + //if our scram handshake is not finished yet and no mutually supported channel-binding can be found --> ignore that for now (see below) + //if our scram handshake finished without negotiating a mutually supported channel-binding and this was not backed by SSDP --> report error + if(self->_scramHandler.serverFirstMessageParsed && !self->_scramHandler.ssdpSupported) + { + DDLogWarn(@"Could not find any supported channel-binding type, this MUST be a mitm attack, because tls-server-end-point is mandatory via XEP-0440!"); + return kServerDoesNotFollowXep0440Error; //this will trigger a disconnect + } + if(!self->_scramHandler.serverFirstMessageParsed) + DDLogWarn(@"Could not find any supported channel-binding type, this COULD be a mitm attack (check via XEP-0474 pending)!"); + return nil; +} + +#pragma mark stanza handling + +// -(AnyPromise*) sendIq:(XMPPIQ*) iq +// { +// return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { +// [self sendIq:iq withResponseHandler:^(XMPPIQ* response) { +// resolve(response); +// } andErrorHandler:^(XMPPIQ* error) { +// resolve(error); +// }]; +// }]; +// } + +-(void) sendIq:(XMPPIQ*) iq withResponseHandler:(monal_iq_handler_t) resultHandler andErrorHandler:(monal_iq_handler_t) errorHandler +{ + if(resultHandler || errorHandler) + @synchronized(_iqHandlers) { + _iqHandlers[iq.id] = [@{@"iq":iq, @"timeout":@(IQ_TIMEOUT), @"resultHandler":resultHandler, @"errorHandler":errorHandler} mutableCopy]; + } + [self send:iq]; +} + +-(void) sendIq:(XMPPIQ*) iq withHandler:(MLHandler*) handler +{ + //serialize this state update with other receive queue updates + //not doing this will make it race with a readState call in the receive queue before the write of this update can happen, + //which will remove this entry from state and the iq answer received later on be discarded + [self dispatchAsyncOnReceiveQueue:^{ + if(handler) + { + DDLogVerbose(@"Adding %@ to iqHandlers...", handler); + @synchronized(self->_iqHandlers) { + self->_iqHandlers[iq.id] = [@{@"iq":iq, @"timeout":@(IQ_TIMEOUT), @"handler":handler} mutableCopy]; + } + } + [self send:iq]; //this will also call persistState --> we don't need to do this here explicitly (to make sure our iq delegate is stored to db) + }]; +} + +-(void) send:(MLXMLNode*) stanza +{ + //proxy to real send + [self send:stanza withSmacks:YES]; +} + +-(void) send:(MLXMLNode*) stanza withSmacks:(BOOL) withSmacks +{ + MLAssert(stanza != nil, @"stanza to send should not be nil!", @{@"withSmacks": @(withSmacks)}); + + [self dispatchAsyncOnReceiveQueue:^{ + //add outgoing mam queryids to our state (but don't persist state because this will be done by smacks code below) + NSString* mamQueryId = [stanza findFirst:@"/{jabber:client}iq/{urn:xmpp:mam:2}query@queryid"]; + if(mamQueryId) + @synchronized(self->_stateLockObject) { + DDLogDebug(@"Adding mam queryid to list: %@", mamQueryId); + self->_runningMamQueries[mamQueryId] = stanza; + } + + //always add stanzas (not nonzas!) to smacks queue to be resent later (if withSmacks=YES) + if(withSmacks && [stanza isKindOfClass:[XMPPStanza class]]) + { + XMPPStanza* queued_stanza = [stanza copy]; + if(![queued_stanza.element isEqualToString:@"iq"]) //add delay tag to message or presence stanzas but not to iq stanzas + { + //only add a delay tag if not already present + if(![queued_stanza check:@"{urn:xmpp:delay}delay"]) + [queued_stanza addDelayTagFrom:self.connectionProperties.identity.jid]; + } + @synchronized(self->_stateLockObject) { + [self logStanza:queued_stanza withPrefix:[NSString stringWithFormat:@"ADD UNACKED STANZA: %@", self.lastOutboundStanza]]; + NSDictionary* dic = @{kQueueID:self.lastOutboundStanza, kStanza:queued_stanza}; + [self.unAckedStanzas addObject:dic]; + //increment for next call + self.lastOutboundStanza = [NSNumber numberWithInteger:[self.lastOutboundStanza integerValue] + 1]; + //persist these changes (this has to be synchronous because we want so persist stanzas to db before actually sending them) + [self persistState]; + } + } + + //only send nonzas if we are >kStateDisconnected and stanzas if we are >=kStateBound + //only exceptions: an outgoing bind request or jabber:iq:register stanza (this is allowed before binding a resource) + BOOL isBindRequest = [stanza isKindOfClass:[XMPPIQ class]] && [stanza check:@"{urn:ietf:params:xml:ns:xmpp-bind}bind/resource"]; + BOOL isRegisterRequest = [stanza isKindOfClass:[XMPPIQ class]] && [stanza check:@"{jabber:iq:register}query"]; + BOOL isPreauthRegisterRequest = [stanza isKindOfClass:[XMPPIQ class]] && [stanza check:@"//{urn:xmpp:pars:0}preauth"]; + if( + self.accountState>=kStateBound || + (self.accountState>kStateDisconnected && (![stanza isKindOfClass:[XMPPStanza class]] || isBindRequest || isRegisterRequest || isPreauthRegisterRequest)) + ) + { + [self->_sendQueue addOperation:[NSBlockOperation blockOperationWithBlock:^{ + [self logStanza:stanza withPrefix:@"SEND"]; + [self->_outputQueue addObject:stanza]; + [self writeFromQueue]; // try to send if there is space + }]]; + } + else + [self logStanza:stanza withPrefix:@"NOT ADDING STANZA TO SEND QUEUE"]; + }]; +} + +-(void) logStanza:(MLXMLNode*) stanza withPrefix:(NSString*) prefix +{ +#if !TARGET_OS_SIMULATOR + if([stanza check:@"/{urn:ietf:params:xml:ns:xmpp-sasl}*"]) + DDLogDebug(@"%@: redacted sasl element: %@", prefix, [stanza findFirst:@"/{urn:ietf:params:xml:ns:xmpp-sasl}*$"]); + else if([stanza check:@"/{jabber:client}iq/{jabber:iq:register}query"]) + DDLogDebug(@"%@: redacted register/change password iq", prefix); + else + DDLogDebug(@"%@: %@", prefix, stanza); +#else + DDLogDebug(@"%@: %@", prefix, stanza); +#endif +} + + +#pragma mark messaging + +-(void) retractMessage:(MLMessage*) msg +{ + MLAssert([msg.accountID isEqual:self.accountID], @"Can not retract message from one account on another account!", (@{@"self.accountID": self.accountID, @"msg": msg})); + XMPPMessage* messageNode = [[XMPPMessage alloc] initWithType:msg.isMuc ? kMessageGroupChatType : kMessageChatType to:msg.buddyName]; + + DDLogVerbose(@"Retracting message: %@", msg); + //retraction + [messageNode addChildNode:[[MLXMLNode alloc] initWithElement:@"retract" andNamespace:@"urn:xmpp:message-retract:1" withAttributes:@{ + @"id": msg.isMuc ? msg.stanzaId : msg.messageId, + } andChildren:@[] andData:nil]]; + + //add fallback indication and fallback body + [messageNode addChildNode:[[MLXMLNode alloc] initWithElement:@"fallback" andNamespace:@"urn:xmpp:fallback:0" withAttributes:@{ + @"for": @"urn:xmpp:message-retract:1", + } andChildren:@[] andData:nil]]; + [messageNode setBody:@"This person attempted to retract a previous message, but it's unsupported by your client."]; + + //for MAM + [messageNode setStoreHint]; + + [self send:messageNode]; +} + +-(void) moderateMessage:(MLMessage*) msg withReason:(NSString*) reason +{ + MLAssert(msg.isMuc, @"Moderated message must be in a muc!"); + + XMPPIQ* iqNode = [[XMPPIQ alloc] initWithType:kiqSetType to:msg.buddyName]; + [iqNode addChildNode:[[MLXMLNode alloc] initWithElement:@"moderate" andNamespace:@"urn:xmpp:message-moderate:1" withAttributes:@{ + @"id": msg.stanzaId, + } andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"retract" andNamespace:@"urn:xmpp:message-retract:1"], + [[MLXMLNode alloc] initWithElement:@"reason" andData:reason], + ] andData:nil]]; + [self sendIq:iqNode withHandler:$newHandler(MLIQProcessor, handleModerationResponse, $ID(msg))]; +} + +-(void) addEME:(NSString*) encryptionNamesapce withName:(NSString* _Nullable) name toMessageNode:(XMPPMessage*) messageNode +{ + if(name) + [messageNode addChildNode:[[MLXMLNode alloc] initWithElement:@"encryption" andNamespace:@"urn:xmpp:eme:0" withAttributes:@{ + @"namespace": encryptionNamesapce, + @"name": name + } andChildren:@[] andData:nil]]; + else + [messageNode addChildNode:[[MLXMLNode alloc] initWithElement:@"encryption" andNamespace:@"urn:xmpp:eme:0" withAttributes:@{ + @"namespace": encryptionNamesapce + } andChildren:@[] andData:nil]]; +} + +-(void) sendMessage:(NSString*) message toContact:(MLContact*) contact isEncrypted:(BOOL) encrypt isUpload:(BOOL) isUpload andMessageId:(NSString*) messageId +{ + [self sendMessage:message toContact:contact isEncrypted:encrypt isUpload:isUpload andMessageId:messageId withLMCId:nil]; +} + +-(void) sendMessage:(NSString*) message toContact:(MLContact*) contact isEncrypted:(BOOL) encrypt isUpload:(BOOL) isUpload andMessageId:(NSString*) messageId withLMCId:(NSString* _Nullable) LMCId +{ + DDLogVerbose(@"sending new outgoing message %@ to %@", messageId, contact.contactJid); + + XMPPMessage* messageNode = [[XMPPMessage alloc] initToContact:contact]; + if(messageId) //use the uuid autogenerated when our message node was created above if no id was supplied + messageNode.id = messageId; + +#ifdef IS_ALPHA + // WARNING NOT FOR PRODUCTION + // encrypt messages that should not be encrypted (but still use plaintext body for devices not speaking omemo) + if(!encrypt && !isUpload && (!contact.isMuc || (contact.isMuc && [contact.mucType isEqualToString:kMucTypeGroup]))) + { + [self.omemo encryptMessage:messageNode withMessage:message toContact:contact.contactJid]; + //[self addEME:@"eu.siacs.conversations.axolotl" withName:@"OMEMO" toMessageNode:messageNode]; + } + // WARNING NOT FOR PRODUCTION END +#endif + +#ifndef DISABLE_OMEMO + if(encrypt && (!contact.isMuc || (contact.isMuc && [contact.mucType isEqualToString:kMucTypeGroup]))) + { + [self.omemo encryptMessage:messageNode withMessage:message toContact:contact.contactJid]; + [self addEME:@"eu.siacs.conversations.axolotl" withName:@"OMEMO" toMessageNode:messageNode]; + } + else +#endif + { + if(isUpload) + [messageNode setOobUrl:message]; + else + [messageNode setBody:message]; + } + + //set message type + if(contact.isMuc) + [messageNode.attributes setObject:kMessageGroupChatType forKey:@"type"]; + else + [messageNode.attributes setObject:kMessageChatType forKey:@"type"]; + + //request receipts and chat-markers in 1:1 or groups (no channels!) + if(!contact.isMuc || [kMucTypeGroup isEqualToString:contact.mucType]) + { + [messageNode addChildNode:[[MLXMLNode alloc] initWithElement:@"request" andNamespace:@"urn:xmpp:receipts"]]; + [messageNode addChildNode:[[MLXMLNode alloc] initWithElement:@"markable" andNamespace:@"urn:xmpp:chat-markers:0"]]; + } + + //for MAM + [messageNode setStoreHint]; + + //handle LMC + if(LMCId) + [messageNode setLMCFor:LMCId]; + + [self send:messageNode]; +} + +-(void) sendChatState:(BOOL) isTyping toContact:(nonnull MLContact*) contact +{ + if(self.accountState < kStateBound) + return; + + XMPPMessage* messageNode = [[XMPPMessage alloc] initToContact:contact]; + [messageNode setNoStoreHint]; + if(isTyping) + [messageNode addChildNode:[[MLXMLNode alloc] initWithElement:@"composing" andNamespace:@"http://jabber.org/protocol/chatstates"]]; + else + [messageNode addChildNode:[[MLXMLNode alloc] initWithElement:@"active" andNamespace:@"http://jabber.org/protocol/chatstates"]]; + [self send:messageNode]; +} + +#pragma mark set connection attributes + +-(void) persistState +{ + DDLogVerbose(@"%@ --> persistState before: used/available memory: %.3fMiB / %.3fMiB)...", self.accountID, [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); + [self realPersistState]; + DDLogVerbose(@"%@ --> persistState after: used/available memory: %.3fMiB / %.3fMiB)...", self.accountID, [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); +} + +-(void) realPersistState +{ + //make sure to create a transaction before locking the state object to prevent the following deadlock: + //thread 1 (for example: receiveQueue): holding write transaction and waiting for state lock object + //thread 2 (for example: urllib session): holding state lock object and waiting for write transaction + [[DataLayer sharedInstance] createTransaction:^{ + @synchronized(self->_stateLockObject) { + DDLogVerbose(@"%@ --> realPersistState before: used/available memory: %.3fMiB / %.3fMiB)...", self.accountID, [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); + //state dictionary + NSMutableDictionary* values = [NSMutableDictionary new]; + + //collect smacks state + [values setValue:self.lastHandledInboundStanza forKey:@"lastHandledInboundStanza"]; + [values setValue:self.lastHandledOutboundStanza forKey:@"lastHandledOutboundStanza"]; + [values setValue:self.lastOutboundStanza forKey:@"lastOutboundStanza"]; + [values setValue:[self.unAckedStanzas copy] forKey:@"unAckedStanzas"]; + [values setValue:self.streamID forKey:@"streamID"]; + [values setObject:[NSNumber numberWithBool:self.isDoingFullReconnect] forKey:@"isDoingFullReconnect"]; + + NSMutableDictionary* persistentIqHandlers = [NSMutableDictionary new]; + NSMutableDictionary* persistentIqHandlerDescriptions = [NSMutableDictionary new]; + @synchronized(self->_iqHandlers) { + for(NSString* iqid in self->_iqHandlers) + if(self->_iqHandlers[iqid][@"handler"] != nil) + { + persistentIqHandlers[iqid] = self->_iqHandlers[iqid]; + persistentIqHandlerDescriptions[iqid] = [NSString stringWithFormat:@"%@: %@", self->_iqHandlers[iqid][@"timeout"], self->_iqHandlers[iqid][@"handler"]]; + } + } + [values setObject:persistentIqHandlers forKey:@"iqHandlers"]; + + @synchronized(self->_reconnectionHandlers) { + [values setObject:[self->_reconnectionHandlers copy] forKey:@"reconnectionHandlers"]; + } + + [values setValue:[self.connectionProperties.serverFeatures copy] forKey:@"serverFeatures"]; + [values setValue:[self.connectionProperties.serverDiscoFeatures copy] forKey:@"serverDiscoFeatures"]; + [values setValue:[self.connectionProperties.accountDiscoFeatures copy] forKey:@"accountDiscoFeatures"]; + + if(self.connectionProperties.serverContactAddresses) + [values setValue:[self.connectionProperties.serverContactAddresses copy] forKey:@"serverContactAddresses"]; + + if(self.connectionProperties.uploadServer) + [values setObject:self.connectionProperties.uploadServer forKey:@"uploadServer"]; + + if(self.connectionProperties.conferenceServers) + [values setObject:self.connectionProperties.conferenceServers forKey:@"conferenceServers"]; + + [values setObject:[self.pubsub getInternalData] forKey:@"pubsubData"]; + [values setObject:[self.mucProcessor getInternalState] forKey:@"mucState"]; + [values setObject:[self->_runningCapsQueries copy] forKey:@"runningCapsQueries"]; + [values setObject:[self->_runningMamQueries copy] forKey:@"runningMamQueries"]; + [values setObject:[NSNumber numberWithBool:self->_loggedInOnce] forKey:@"loggedInOnce"]; + [values setObject:[NSNumber numberWithBool:self.connectionProperties.usingCarbons2] forKey:@"usingCarbons2"]; + [values setObject:[NSNumber numberWithBool:self.connectionProperties.supportsBookmarksCompat] forKey:@"supportsBookmarksCompat"]; + [values setObject:[NSNumber numberWithBool:self.connectionProperties.pushEnabled] forKey:@"pushEnabled"]; + [values setObject:[NSNumber numberWithBool:self.connectionProperties.supportsPubSub] forKey:@"supportsPubSub"]; + [values setObject:[NSNumber numberWithBool:self.connectionProperties.supportsPubSubMax] forKey:@"supportsPubSubMax"]; + [values setObject:[NSNumber numberWithBool:self.connectionProperties.supportsModernPubSub] forKey:@"supportsModernPubSub"]; + [values setObject:[NSNumber numberWithBool:self.connectionProperties.supportsHTTPUpload] forKey:@"supportsHTTPUpload"]; + [values setObject:[NSNumber numberWithBool:self.connectionProperties.accountDiscoDone] forKey:@"accountDiscoDone"]; + [values setObject:[self->_inCatchup copy] forKey:@"inCatchup"]; + [values setObject:[self->_mdsData copy] forKey:@"mdsData"]; + + if(self->_cachedStreamFeaturesBeforeAuth != nil) + [values setObject:self->_cachedStreamFeaturesBeforeAuth forKey:@"cachedStreamFeaturesBeforeAuth"]; + if(self->_cachedStreamFeaturesAfterAuth != nil) + [values setObject:self->_cachedStreamFeaturesAfterAuth forKey:@"cachedStreamFeaturesAfterAuth"]; + + if(self.connectionProperties.discoveredServices) + [values setObject:[self.connectionProperties.discoveredServices copy] forKey:@"discoveredServices"]; + + if(self.connectionProperties.discoveredStunTurnServers) + [values setObject:[self.connectionProperties.discoveredStunTurnServers copy] forKey:@"discoveredStunTurnServers"]; + + if(self.connectionProperties.discoveredAdhocCommands) + [values setObject:[self.connectionProperties.discoveredAdhocCommands copy] forKey:@"discoveredAdhocCommands"]; + + if(self.connectionProperties.serverVersion) + [values setObject:self.connectionProperties.serverVersion forKey:@"serverVersion"]; + + [values setObject:self->_lastInteractionDate forKey:@"lastInteractionDate"]; + [values setValue:[NSDate date] forKey:@"stateSavedAt"]; + [values setValue:@(STATE_VERSION) forKey:@"VERSION"]; + + if(self.omemo != nil && self.omemo.state != nil) + [values setObject:self.omemo.state forKey:@"omemoState"]; + + [values setObject:[NSNumber numberWithBool:self.hasSeenOmemoDeviceListAfterOwnDeviceid] forKey:@"hasSeenOmemoDeviceListAfterOwnDeviceid"]; + + //save state dictionary + [[DataLayer sharedInstance] persistState:values forAccount:self.accountID]; + + //debug output + DDLogVerbose(@"%@ --> persistState(saved at %@):\n\tisDoingFullReconnect=%@,\n\tlastHandledInboundStanza=%@,\n\tlastHandledOutboundStanza=%@,\n\tlastOutboundStanza=%@,\n\t#unAckedStanzas=%lu%s,\n\tstreamID=%@\n\tlastInteractionDate=%@\n\tpersistentIqHandlers=%@\n\tsupportsHttpUpload=%d\n\tpushEnabled=%d\n\tsupportsPubSub=%d\n\tsupportsModernPubSub=%d\n\tsupportsPubSubMax=%d\n\tsupportsBookmarksCompat=%d\n\taccountDiscoDone=%d\n\t_inCatchup=%@\n\tomemo.state=%@\n\thasSeenOmemoDeviceListAfterOwnDeviceid=%@\n", + self.accountID, + values[@"stateSavedAt"], + bool2str(self.isDoingFullReconnect), + self.lastHandledInboundStanza, + self.lastHandledOutboundStanza, + self.lastOutboundStanza, + self.unAckedStanzas ? [self.unAckedStanzas count] : 0, self.unAckedStanzas ? "" : " (NIL)", + self.streamID, + self->_lastInteractionDate, + persistentIqHandlerDescriptions, + self.connectionProperties.supportsHTTPUpload, + self.connectionProperties.pushEnabled, + self.connectionProperties.supportsPubSub, + self.connectionProperties.supportsModernPubSub, + self.connectionProperties.supportsPubSubMax, + self.connectionProperties.supportsBookmarksCompat, + self.connectionProperties.accountDiscoDone, + self->_inCatchup, + self.omemo.state, + bool2str(self.hasSeenOmemoDeviceListAfterOwnDeviceid) + ); + DDLogVerbose(@"%@ --> realPersistState after: used/available memory: %.3fMiB / %.3fMiB)...", self.accountID, [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); + } + }]; +} + +-(void) readState +{ + DDLogVerbose(@"%@ --> readState before: used/available memory: %.3fMiB / %.3fMiB)...", self.accountID, [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); + [self realReadState]; + DDLogVerbose(@"%@ --> readState after: used/available memory: %.3fMiB / %.3fMiB)...", self.accountID, [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); +} + +-(void) realReadState +{ + @synchronized(_stateLockObject) { + DDLogVerbose(@"%@ --> realReadState before: used/available memory: %.3fMiB / %.3fMiB)...", self.accountID, [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); + NSMutableDictionary* dic = [[DataLayer sharedInstance] readStateForAccount:self.accountID]; + if(dic) + { + //check state version + int oldVersion = [dic[@"VERSION"] intValue]; + if(oldVersion != STATE_VERSION) + { + DDLogWarn(@"Account state upgraded from %@ to %d, invalidating state...", dic[@"VERSION"], STATE_VERSION); + dic = [[self class] invalidateState:dic]; + + //don't show deviceid alerts on state update (if we need to regenerate our own deviceid, MLOMEMO will reset this to NO anyways) + if(oldVersion <= 16) + self.hasSeenOmemoDeviceListAfterOwnDeviceid = YES; + } + + //collect smacks state + self.lastHandledInboundStanza = [dic objectForKey:@"lastHandledInboundStanza"]; + self.lastHandledOutboundStanza = [dic objectForKey:@"lastHandledOutboundStanza"]; + self.lastOutboundStanza = [dic objectForKey:@"lastOutboundStanza"]; + NSArray* stanzas = [dic objectForKey:@"unAckedStanzas"]; + self.unAckedStanzas = [stanzas mutableCopy]; + self.streamID = [dic objectForKey:@"streamID"]; + if([dic objectForKey:@"isDoingFullReconnect"]) + { + NSNumber* isDoingFullReconnect = [dic objectForKey:@"isDoingFullReconnect"]; + self.isDoingFullReconnect = isDoingFullReconnect.boolValue; + } + + @synchronized(_stateLockObject) { + //invalidate corrupt smacks states (this could potentially loose messages, but hey, the state is corrupt anyways) + if(self.lastHandledInboundStanza == nil || self.lastHandledOutboundStanza == nil || self.lastOutboundStanza == nil || !self.unAckedStanzas) + { +#ifndef IS_ALPHA + [self initSM3]; +#else + @throw [NSException exceptionWithName:@"RuntimeError" reason:@"corrupt smacks state" userInfo:dic]; +#endif + } + } + + NSDictionary* persistentIqHandlers = [dic objectForKey:@"iqHandlers"]; + NSMutableDictionary* persistentIqHandlerDescriptions = [NSMutableDictionary new]; + @synchronized(_iqHandlers) { + //remove all current persistent handlers... + NSMutableDictionary* handlersCopy = [_iqHandlers copy]; + for(NSString* iqid in handlersCopy) + if(handlersCopy[iqid][@"handler"] != nil) + [_iqHandlers removeObjectForKey:iqid]; + //...and replace them with persistent handlers loaded from state + for(NSString* iqid in persistentIqHandlers) + { + _iqHandlers[iqid] = [persistentIqHandlers[iqid] mutableCopy]; + persistentIqHandlerDescriptions[iqid] = [NSString stringWithFormat:@"%@: %@", persistentIqHandlers[iqid][@"timeout"], persistentIqHandlers[iqid][@"handler"]]; + } + } + + @synchronized(self->_reconnectionHandlers) { + [_reconnectionHandlers removeAllObjects]; + [_reconnectionHandlers addObjectsFromArray:[dic objectForKey:@"reconnectionHandlers"]]; + } + + self.connectionProperties.serverFeatures = [dic objectForKey:@"serverFeatures"]; + self.connectionProperties.serverDiscoFeatures = [dic objectForKey:@"serverDiscoFeatures"]; + self.connectionProperties.accountDiscoFeatures = [dic objectForKey:@"accountDiscoFeatures"]; + + self.connectionProperties.serverContactAddresses = [dic objectForKey:@"serverContactAddresses"]; + + self.connectionProperties.discoveredServices = [[dic objectForKey:@"discoveredServices"] mutableCopy]; + self.connectionProperties.discoveredStunTurnServers = [[dic objectForKey:@"discoveredStunTurnServers"] mutableCopy]; + self.connectionProperties.discoveredAdhocCommands = [[dic objectForKey:@"discoveredAdhocCommands"] mutableCopy]; + self.connectionProperties.serverVersion = [dic objectForKey:@"serverVersion"]; + + self.connectionProperties.uploadServer = [dic objectForKey:@"uploadServer"]; + self.connectionProperties.conferenceServers = [[dic objectForKey:@"conferenceServers"] mutableCopy]; + + if([dic objectForKey:@"loggedInOnce"]) + { + NSNumber* loggedInOnce = [dic objectForKey:@"loggedInOnce"]; + _loggedInOnce = loggedInOnce.boolValue; + } + + if([dic objectForKey:@"usingCarbons2"]) + { + NSNumber* carbonsNumber = [dic objectForKey:@"usingCarbons2"]; + self.connectionProperties.usingCarbons2 = carbonsNumber.boolValue; + } + + if([dic objectForKey:@"supportsBookmarksCompat"]) + { + NSNumber* compatNumber = [dic objectForKey:@"supportsBookmarksCompat"]; + self.connectionProperties.supportsBookmarksCompat = compatNumber.boolValue; + } + + if([dic objectForKey:@"pushEnabled"]) + { + NSNumber* pushEnabled = [dic objectForKey:@"pushEnabled"]; + self.connectionProperties.pushEnabled = pushEnabled.boolValue; + } + + if([dic objectForKey:@"supportsPubSub"]) + { + NSNumber* supportsPubSub = [dic objectForKey:@"supportsPubSub"]; + self.connectionProperties.supportsPubSub = supportsPubSub.boolValue; + } + + if([dic objectForKey:@"supportsPubSubMax"]) + { + NSNumber* supportsPubSubMax = [dic objectForKey:@"supportsPubSubMax"]; + self.connectionProperties.supportsPubSubMax = supportsPubSubMax.boolValue; + } + + if([dic objectForKey:@"supportsModernPubSub"]) + { + NSNumber* supportsModernPubSub = [dic objectForKey:@"supportsModernPubSub"]; + self.connectionProperties.supportsModernPubSub = supportsModernPubSub.boolValue; + } + + if([dic objectForKey:@"supportsHTTPUpload"]) + { + NSNumber* supportsHTTPUpload = [dic objectForKey:@"supportsHTTPUpload"]; + self.connectionProperties.supportsHTTPUpload = supportsHTTPUpload.boolValue; + } + + if([dic objectForKey:@"lastInteractionDate"]) + _lastInteractionDate = [dic objectForKey:@"lastInteractionDate"]; + + if([dic objectForKey:@"accountDiscoDone"]) + { + NSNumber* accountDiscoDone = [dic objectForKey:@"accountDiscoDone"]; + self.connectionProperties.accountDiscoDone = accountDiscoDone.boolValue; + } + + if([dic objectForKey:@"pubsubData"]) + [self.pubsub setInternalData:[dic objectForKey:@"pubsubData"]]; + + if([dic objectForKey:@"mucState"]) + [self.mucProcessor setInternalState:[dic objectForKey:@"mucState"]]; + + if([dic objectForKey:@"runningCapsQueries"]) + _runningCapsQueries = [[dic objectForKey:@"runningCapsQueries"] mutableCopy]; + + if([dic objectForKey:@"runningMamQueries"]) + _runningMamQueries = [[dic objectForKey:@"runningMamQueries"] mutableCopy]; + + if([dic objectForKey:@"inCatchup"]) + _inCatchup = [[dic objectForKey:@"inCatchup"] mutableCopy]; + + if([dic objectForKey:@"mdsData"]) + _mdsData = [[dic objectForKey:@"mdsData"] mutableCopy]; + + if([dic objectForKey:@"cachedStreamFeaturesBeforeAuth"]) + _cachedStreamFeaturesBeforeAuth = [dic objectForKey:@"cachedStreamFeaturesBeforeAuth"]; + if([dic objectForKey:@"cachedStreamFeaturesAfterAuth"]) + _cachedStreamFeaturesAfterAuth = [dic objectForKey:@"cachedStreamFeaturesAfterAuth"]; + + if([dic objectForKey:@"omemoState"] && self.omemo) + self.omemo.state = [dic objectForKey:@"omemoState"]; + + if([dic objectForKey:@"hasSeenOmemoDeviceListAfterOwnDeviceid"]) + { + NSNumber* hasSeenOmemoDeviceListAfterOwnDeviceid = [dic objectForKey:@"hasSeenOmemoDeviceListAfterOwnDeviceid"]; + self.hasSeenOmemoDeviceListAfterOwnDeviceid = hasSeenOmemoDeviceListAfterOwnDeviceid.boolValue; + } + + //debug output + DDLogVerbose(@"%@ --> readState(saved at %@):\n\tisDoingFullReconnect=%@,\n\tlastHandledInboundStanza=%@,\n\tlastHandledOutboundStanza=%@,\n\tlastOutboundStanza=%@,\n\t#unAckedStanzas=%lu%s,\n\tstreamID=%@,\n\tlastInteractionDate=%@\n\tpersistentIqHandlers=%@\n\tsupportsHttpUpload=%d\n\tpushEnabled=%d\n\tsupportsPubSub=%d\n\tsupportsModernPubSub=%d\n\tsupportsPubSubMax=%d\n\tsupportsBookmarksCompat=%d\n\taccountDiscoDone=%d\n\t_inCatchup=%@\n\tomemo.state=%@\n\thasSeenOmemoDeviceListAfterOwnDeviceid=%@\n", + self.accountID, + dic[@"stateSavedAt"], + bool2str(self.isDoingFullReconnect), + self.lastHandledInboundStanza, + self.lastHandledOutboundStanza, + self.lastOutboundStanza, + self.unAckedStanzas ? [self.unAckedStanzas count] : 0, self.unAckedStanzas ? "" : " (NIL)", + self.streamID, + self->_lastInteractionDate, + persistentIqHandlerDescriptions, + self.connectionProperties.supportsHTTPUpload, + self.connectionProperties.pushEnabled, + self.connectionProperties.supportsPubSub, + self.connectionProperties.supportsModernPubSub, + self.connectionProperties.supportsPubSubMax, + self.connectionProperties.supportsBookmarksCompat, + self.connectionProperties.accountDiscoDone, + self->_inCatchup, + self.omemo.state, + bool2str(self.hasSeenOmemoDeviceListAfterOwnDeviceid) + ); + if(self.unAckedStanzas) + for(NSDictionary* dic in self.unAckedStanzas) + DDLogDebug(@"readState unAckedStanza %@: %@", [dic objectForKey:kQueueID], [dic objectForKey:kStanza]); + } + + //always reset handler and smacksRequestInFlight when loading smacks state + _smacksAckHandler = [NSMutableArray new]; + self.smacksRequestInFlight = NO; + + DDLogVerbose(@"%@ --> realReadState after: used/available memory: %.3fMiB / %.3fMiB)...", self.accountID, [HelperTools report_memory], (CGFloat)os_proc_available_memory() / 1048576); + } +} + ++(NSMutableDictionary*) invalidateState:(NSDictionary*) dic +{ + NSArray* toKeep = @[@"lastHandledInboundStanza", @"lastHandledOutboundStanza", @"lastOutboundStanza", @"unAckedStanzas", @"loggedInOnce", @"lastInteractionDate", @"inCatchup", @"hasSeenOmemoDeviceListAfterOwnDeviceid"]; + + NSMutableDictionary* newState = [NSMutableDictionary new]; + if(dic) + { + for(NSString* entry in toKeep) + if(dic[entry] != nil) + newState[entry] = dic[entry]; + } + + //set smacks state to sane defaults if not present in our old state at all (this are the values used by initSM3, too) + if(newState[@"lastHandledInboundStanza"] == nil) + newState[@"lastHandledInboundStanza"] = [NSNumber numberWithInteger:0]; + if(newState[@"lastHandledOutboundStanza"] == nil) + newState[@"lastHandledOutboundStanza"] = [NSNumber numberWithInteger:0]; + if(newState[@"lastOutboundStanza"] == nil) + newState[@"lastOutboundStanza"] = [NSNumber numberWithInteger:0]; + if(newState[@"unAckedStanzas"] == nil) + newState[@"unAckedStanzas"] = [NSMutableArray new]; + + newState[@"stateSavedAt"] = [NSDate date]; + newState[@"VERSION"] = @(STATE_VERSION); + + return newState; +} + +-(void) incrementLastHandledStanzaWithDelayedReplay:(BOOL) delayedReplay +{ + //don't ack messages twice + if(delayedReplay) + return; + @synchronized(_stateLockObject) { + if(self.connectionProperties.supportsSM3) + { + //this will count any stanza between our bind result and smacks enable result but gets reset to sane values + //once the smacks enable result surfaces (e.g. the wrong counting will be ignored later) + if(self.accountState>=kStateBound) + self.lastHandledInboundStanza = [NSNumber numberWithInteger:[self.lastHandledInboundStanza integerValue] + 1]; + } + [self persistState]; //make sure we persist our state, even if smacks is not supported + } +} + +-(void) initSM3 +{ + //initialize smacks state + @synchronized(_stateLockObject) { + self.lastHandledInboundStanza = [NSNumber numberWithInteger:0]; + self.lastHandledOutboundStanza = [NSNumber numberWithInteger:0]; + self.lastOutboundStanza = [NSNumber numberWithInteger:0]; + self.unAckedStanzas = [NSMutableArray new]; + self.streamID = nil; + _smacksAckHandler = [NSMutableArray new]; + DDLogDebug(@"initSM3 done"); + } +} + +-(void) bindResource:(NSString*) resource +{ + if(resource == nil) + return [self bindResource:[HelperTools encodeRandomResource]]; + //check if our resource is a modern one and change it to a modern one if not + //this should fix rare bugs when monal was first installed a long time ago when the resource didn't yet had a random part + NSArray* parts = [resource componentsSeparatedByString:@"."]; + if([parts count] < 2 || [[HelperTools dataWithHexString:parts[1]] length] < 1) + return [self bindResource:[HelperTools encodeRandomResource]]; + + self.isDoingFullReconnect = YES; + _accountState = kStateBinding; + + //delete old resources because we get new presences once we're done initializing the session + [[DataLayer sharedInstance] resetContactsForAccount:self.accountID]; + + //inform all old iq handlers of invalidation and clear _iqHandlers dictionary afterwards + @synchronized(_iqHandlers) { + //make sure this works even if the invalidation handlers add a new iq to the list + NSMutableDictionary* handlersCopy = [_iqHandlers mutableCopy]; + [_iqHandlers removeAllObjects]; + + for(NSString* iqid in handlersCopy) + { + DDLogWarn(@"Invalidating iq handler for iq id '%@'", iqid); + if(handlersCopy[iqid][@"handler"] != nil) + $invalidate(handlersCopy[iqid][@"handler"], $ID(account, self), $ID(reason, @"bind")); + else if(handlersCopy[iqid][@"errorHandler"]) + ((monal_iq_handler_t)handlersCopy[iqid][@"errorHandler"])(nil); + } + + } + + //invalidate pubsub queue (a pubsub operation will be either invalidated by an iq handler above OR by the invalidation here, but never twice!) + [self.pubsub invalidateQueue]; + + //clean up all idle timers + [[DataLayer sharedInstance] cleanupIdleTimerOnAccountID:self.accountID]; + + //force new disco queries because we landed here because of a failed smacks resume + //(or the account got forcibly disconnected/reconnected or this is the very first login of this account) + //--> all of this reasons imply that we had to start a new xmpp stream and our old cached disco data + // and other state values are stale now + //(smacks state will be reset/cleared later on if appropriate, no need to handle smacks here) + self.connectionProperties.serverDiscoFeatures = [NSSet new]; + self.connectionProperties.accountDiscoFeatures = [NSSet new]; + self.connectionProperties.serverContactAddresses = [NSDictionary new]; + self.connectionProperties.discoveredServices = [NSMutableArray new]; + self.connectionProperties.discoveredStunTurnServers = [NSMutableArray new]; + self.connectionProperties.discoveredAdhocCommands = [NSMutableDictionary new]; + self.connectionProperties.serverVersion = nil; + self.connectionProperties.conferenceServers = [NSMutableDictionary new]; + self.connectionProperties.supportsHTTPUpload = NO; + self.connectionProperties.uploadServer = nil; + //self.connectionProperties.supportsSM3 = NO; //already set by stream feature parsing + self.connectionProperties.pushEnabled = NO; + self.connectionProperties.supportsBookmarksCompat = NO; + self.connectionProperties.usingCarbons2 = NO; + //self.connectionProperties.serverIdentity = @""; //already set by stream feature parsing + self.connectionProperties.supportsPubSub = NO; + self.connectionProperties.supportsPubSubMax = NO; + self.connectionProperties.supportsModernPubSub = NO; + self.connectionProperties.accountDiscoDone = NO; + + //clear list of running mam queries + _runningMamQueries = [NSMutableDictionary new]; + + //clear list of running caps queries + _runningCapsQueries = [NSMutableSet new]; + + //clear old catchup state (technically all stanzas still in delayedMessageStanzas could have also been + //in the parseQueue in the last run and deleted there) + //--> no harm in deleting them when starting a new session (but DON'T DELETE them when resuming the old smacks session) + _inCatchup = [NSMutableDictionary new]; + [[DataLayer sharedInstance] deleteDelayedMessageStanzasForAccount:self.accountID]; + + //send bind iq + XMPPIQ* iqNode = [[XMPPIQ alloc] initWithType:kiqSetType]; + [iqNode setBindWithResource:resource]; + [self sendIq:iqNode withHandler:$newHandler(MLIQProcessor, handleBind)]; +} + +-(void) queryDisco +{ + XMPPIQ* discoInfo = [[XMPPIQ alloc] initWithType:kiqGetType to:self.connectionProperties.identity.domain]; + [discoInfo setDiscoInfoNode]; + [self sendIq:discoInfo withHandler:$newHandler(MLIQProcessor, handleServerDiscoInfo)]; + + XMPPIQ* discoItems = [[XMPPIQ alloc] initWithType:kiqGetType to:self.connectionProperties.identity.domain]; + [discoItems setDiscoItemNode]; + [self sendIq:discoItems withHandler:$newHandler(MLIQProcessor, handleServerDiscoItems)]; + + XMPPIQ* accountInfo = [[XMPPIQ alloc] initWithType:kiqGetType to:self.connectionProperties.identity.jid]; + [accountInfo setDiscoInfoNode]; + [self sendIq:accountInfo withHandler:$newHandler(MLIQProcessor, handleAccountDiscoInfo)]; + + XMPPIQ* adhocCommands = [[XMPPIQ alloc] initWithType:kiqGetType to:self.connectionProperties.identity.domain]; + [adhocCommands setAdhocDiscoNode]; + [self sendIq:adhocCommands withHandler:$newHandler(MLIQProcessor, handleAdhocDisco)]; +} + +-(void) queryServerVersion +{ + XMPPIQ* serverVersion = [[XMPPIQ alloc] initWithType:kiqGetType to:self.connectionProperties.identity.domain]; + [serverVersion getEntitySoftwareVersionInfo]; + [self sendIq:serverVersion withHandler:$newHandler(MLIQProcessor, handleVersionResponse)]; +} + +-(void) queryExternalServicesOn:(NSString*) jid +{ + XMPPIQ* externalDisco = [[XMPPIQ alloc] initWithType:kiqGetType]; + [externalDisco setiqTo:jid]; + [externalDisco addChildNode:[[MLXMLNode alloc] initWithElement:@"services" andNamespace:@"urn:xmpp:extdisco:2"]]; + [self sendIq:externalDisco withHandler:$newHandler(MLIQProcessor, handleExternalDisco)]; +} + +-(void) queryExternalServiceCredentialsFor:(NSDictionary*) service completion:(monal_id_block_t) completion +{ + XMPPIQ* credentialsQuery = [[XMPPIQ alloc] initWithType:kiqGetType]; + [credentialsQuery setiqTo:service[@"directoryJid"]]; + [credentialsQuery addChildNode:[[MLXMLNode alloc] initWithElement:@"credentials" andNamespace:@"urn:xmpp:extdisco:2" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"service" withAttributes:@{ + @"type": service[@"type"], + @"host": service[@"host"], + @"port": service[@"port"], + } andChildren:@[] andData:nil] + ] andData:nil]]; + [self sendIq:credentialsQuery withResponseHandler:^(XMPPIQ* response) { + completion([response findFirst:@"{urn:xmpp:extdisco:2}credentials/service@@"]); + } andErrorHandler:^(XMPPIQ* error) { + DDLogWarn(@"Got error while quering for credentials of external service %@: %@", service, error); + completion(@{}); + }]; +} + +-(void) purgeOfflineStorage +{ + XMPPIQ* purgeIq = [[XMPPIQ alloc] initWithType:kiqSetType]; + [purgeIq setPurgeOfflineStorage]; + [self sendIq:purgeIq withResponseHandler:^(XMPPIQ* response __unused) { + DDLogInfo(@"Successfully purged offline storage..."); + } andErrorHandler:^(XMPPIQ* error) { + DDLogWarn(@"Could not purge offline storage (using XEP-0013): %@", error); + }]; +} + +-(void) sendPresence +{ + //don't send presences if we are not bound + if(_accountState < kStateBound) + return; + + XMPPPresence* presence = [[XMPPPresence alloc] initWithHash:_capsHash]; + if(![self.statusMessage isEqualToString:@""]) + [presence setStatus:self.statusMessage]; + + //send last interaction date if not currently active + //and the user prefers to send out lastInteraction date + if(!_isCSIActive && [[HelperTools defaultsDB] boolForKey:@"SendLastUserInteraction"]) + [presence setLastInteraction:_lastInteractionDate]; + + [self send:presence]; +} + +-(void) fetchRoster +{ + XMPPIQ* roster = [[XMPPIQ alloc] initWithType:kiqGetType]; + NSString* rosterVer; + if([self.connectionProperties.serverFeatures check:@"{urn:xmpp:features:rosterver}ver"]) + rosterVer = [[DataLayer sharedInstance] getRosterVersionForAccount:self.accountID]; + [roster setRosterRequest:rosterVer]; + [self sendIq:roster withHandler:$newHandler(MLIQProcessor, handleRoster)]; +} + +-(void) initSession +{ + DDLogInfo(@"Now bound, initializing new xmpp session"); + self.isDoingFullReconnect = YES; + + //we are now bound + _connectedTime = [NSDate date]; + _reconnectBackoffTime = 0; + + //indicate we are bound now, *after* initializing/resetting all the other data structures to avoid race conditions + _accountState = kStateBound; + + //inform other parts of monal about our new state + [[MLNotificationQueue currentQueue] postNotificationName:kMLResourceBoundNotice object:self]; + [self accountStatusChanged]; + + //now fetch roster, request disco and send initial presence + [self fetchRoster]; + + //query disco *before* sending out our first presence because this presence will trigger pubsub "headline" updates and we want to know + //if and what pubsub/pep features the server supports, before handling that + //we can pipeline the disco requests and outgoing presence broadcast, though + [self queryDisco]; + [self queryServerVersion]; + [self purgeOfflineStorage]; + [self setMAMPrefs:@"always"]; //make sure we are able to do proper catchups + [self sendPresence]; //this will trigger a replay of offline stanzas on prosody (no XEP-0013 support anymore 😡) + //the offline messages will come in *after* we initialized the mam query, because the disco result comes in first + //(and this is what triggers mam catchup) + //--> no holes in our history can be caused by these offline messages in conjunction with mam catchup, + // however all offline messages will be received twice (as offline message AND via mam catchup) + + //send own csi state (this must be done *after* presences to not delay/filter incoming presence flood needed to prime our database + [self sendCurrentCSIState]; + + //only do this if smacks is not supported because handling of the old queue will be already done on smacks enable/failed enable + if(!self.connectionProperties.supportsSM3) + { + //resend stanzas still in the outgoing queue and clear it afterwards + //this happens if the server has internal problems and advertises smacks support + //but fails to resume the stream as well as to enable smacks on the new stream + //clean up those stanzas to only include message stanzas because iqs don't survive a session change + //message duplicates are possible in this scenario, but that's better than dropping messages + //initSession() above does not add message stanzas to the self.unAckedStanzas queue --> this is safe to do + [self resendUnackedMessageStanzasOnly:self.unAckedStanzas]; + } + + //fetch current mds state + [self.pubsub fetchNode:@"urn:xmpp:mds:displayed:0" from:self.connectionProperties.identity.jid withItemsList:nil andHandler:$newHandler(MLPubSubProcessor, handleMdsFetchResult)]; + + //NOTE: mam query will be done in MLIQProcessor once the disco result for our own jid/account returns + + //initialize stanza counter for statistics + [self initCatchupStats]; +} + +-(void) addReconnectionHandler:(MLHandler*) handler +{ + //don't check if we are bound and execute the handler directly if so + //--> reconnect handlers are frequently used while being bound to schedule a task on *next* (re)connect + //--> in cases where the reconnect handler is only needed if we are not bound, the caller can do this check itself + // (this might introduce small race conditions, though, but these should be negligible in most cases) + @synchronized(_reconnectionHandlers) { + [_reconnectionHandlers addObject:handler]; + } + [self persistState]; +} + +-(void) setBlocked:(BOOL) blocked forJid:(NSString* _Nonnull) blockedJid +{ + if(![self.connectionProperties.serverDiscoFeatures containsObject:@"urn:xmpp:blocking"]) + { + DDLogWarn(@"Server does not support blocking..."); + return; + } + + XMPPIQ* iqBlocked = [[XMPPIQ alloc] initWithType:kiqSetType]; + + [iqBlocked setBlocked:blocked forJid:blockedJid]; + [self sendIq:iqBlocked withHandler:$newHandler(MLIQProcessor, handleBlocked, $ID(blockedJid))]; +} + +-(void) fetchBlocklist +{ + if(![self.connectionProperties.serverDiscoFeatures containsObject:@"urn:xmpp:blocking"]) + { + DDLogWarn(@"Server does not support blocking..."); + return; + } + + XMPPIQ* iqBlockList = [[XMPPIQ alloc] initWithType:kiqGetType]; + + [iqBlockList requestBlockList]; + [self sendIq:iqBlockList withHandler:$newHandler(MLIQProcessor, handleBlocklist)];; +} + +-(void) updateLocalBlocklistCache:(NSSet*) blockedJids +{ + [[DataLayer sharedInstance] updateLocalBlocklistCache:blockedJids forAccountID:self.accountID]; +} + +#pragma mark vcard + +-(void) getEntitySoftWareVersion:(NSString*) jid +{ + NSDictionary* split = [HelperTools splitJid:jid]; + MLAssert(split[@"resource"] != nil, @"getEntitySoftWareVersion needs a full jid!"); + if([[DataLayer sharedInstance] checkCap:@"jabber:iq:version" forUser:split[@"user"] andResource:split[@"resource"] onAccountID:self.accountID]) + { + XMPPIQ* iqEntitySoftWareVersion = [[XMPPIQ alloc] initWithType:kiqGetType to:jid]; + [iqEntitySoftWareVersion getEntitySoftwareVersionInfo]; + [self sendIq:iqEntitySoftWareVersion withHandler:$newHandler(MLIQProcessor, handleVersionResponse)]; + } +} + +#pragma mark HTTP upload + +-(void) requestHTTPSlotWithParams:(NSDictionary*) params andCompletion:(void(^)(NSString* url, NSError* error)) completion +{ + XMPPIQ* httpSlotRequest = [[XMPPIQ alloc] initWithType:kiqGetType]; + [httpSlotRequest setiqTo:self.connectionProperties.uploadServer]; + [httpSlotRequest + httpUploadforFile:params[@"fileName"] + ofSize:[NSNumber numberWithInteger:((NSData*)params[@"data"]).length] + andContentType:params[@"contentType"] + ]; + [self sendIq:httpSlotRequest withResponseHandler:^(XMPPIQ* response) { + DDLogInfo(@"Got slot for upload: %@", [response findFirst:@"{urn:xmpp:http:upload:0}slot/put@url"]); + //upload to server using HTTP PUT + NSMutableDictionary* headers = [NSMutableDictionary new]; + headers[@"Content-Type"] = params[@"contentType"]; + for(MLXMLNode* header in [response find:@"{urn:xmpp:http:upload:0}slot/put/header"]) + headers[[header findFirst:@"/@name"]] = [header findFirst:@"/#"]; + dispatch_async(dispatch_get_main_queue(), ^{ + [MLHTTPRequest + sendWithVerb:kPut path:[response findFirst:@"{urn:xmpp:http:upload:0}slot/put@url"] + headers:headers + withArguments:nil + data:params[@"data"] + andCompletionHandler:^(NSError* error, id result __unused) { + if(!error) + { + DDLogInfo(@"Upload succeded, get url: %@", [response findFirst:@"{urn:xmpp:http:upload:0}slot/get@url"]); + //send get url to contact + if(completion) + completion([response findFirst:@"{urn:xmpp:http:upload:0}slot/get@url"], nil); + } + else + { + DDLogInfo(@"Upload failed, error: %@", error); + if(completion) + completion(nil, error); + } + } + ]; + }); + } andErrorHandler:^(XMPPIQ* error) { + if(completion) + completion(nil, error == nil ? [NSError errorWithDomain:@"MonalError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Upload Error: your account got disconnected while requesting upload slot", @"")}] : [NSError errorWithDomain:@"MonalError" code:0 userInfo:@{NSLocalizedDescriptionKey: [HelperTools extractXMPPError:error withDescription:NSLocalizedString(@"Upload Error", @"")]}]); + }]; +} + +#pragma mark client state +-(void) setClientActive +{ + [self dispatchAsyncOnReceiveQueue: ^{ + //ignore active --> active transition + if(self->_isCSIActive) + { + DDLogVerbose(@"Ignoring CSI transition from active to active"); + return; + } + + //record new csi state and send csi nonza + self->_isCSIActive = YES; + [self sendCurrentCSIState]; + + //to make sure this date is newer than the old saved one (even if we now falsely "tag" the beginning of our interaction, not the end) + //if everything works out as it should and the app does not get killed, we will "tag" the end of our interaction as soon as the app is backgrounded + self->_lastInteractionDate = [NSDate date]; + [self persistState]; + + //this will broadcast our presence without idle element, because of _isCSIActive=YES + //(presence without idle indicates the client is now active, see XEP-0319) + if([[HelperTools defaultsDB] boolForKey:@"SendLastUserInteraction"]) + [self sendPresence]; + }]; +} + +-(void) setClientInactive +{ + [self dispatchAsyncOnReceiveQueue: ^{ + //ignore inactive --> inactive transition + if(!self->_isCSIActive) + { + DDLogVerbose(@"Ignoring CSI transition from INactive to INactive"); + return; + } + + //save date as last interaction date (XEP-0319) (e.g. "tag" the end of our interaction) + self->_lastInteractionDate = [NSDate date]; + [self persistState]; + + //record new state + self->_isCSIActive = NO; + + //this will broadcast our presence with idle element set, because of _isCSIActive=NO (see XEP-0319) + if([[HelperTools defaultsDB] boolForKey:@"SendLastUserInteraction"]) + [self sendPresence]; + + //send csi inactive nonza *after* broadcasting our presence + [self sendCurrentCSIState]; + + //proactively send smacks ACK to make sure the server knows what stanzas have been received and processed by us + //even if the time after going into the background shortly after receiving a stanza may be too short for the server + //to request an ack and for us to process and answer this request before apple freezes us + [self sendSMAck:YES]; + }]; +} + +-(void) sendCurrentCSIState +{ + [self dispatchOnReceiveQueue: ^{ + //don't send anything before a resource is bound + if(self.accountState_isCSIActive) + csiNode = [[MLXMLNode alloc] initWithElement:@"active" andNamespace:@"urn:xmpp:csi:0"]; + else + csiNode = [[MLXMLNode alloc] initWithElement:@"inactive" andNamespace:@"urn:xmpp:csi:0"]; + [self send:csiNode]; + }]; +} + +#pragma mark - Message archive + + +-(void) setMAMPrefs:(NSString*) preference +{ + if(![self.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:mam:2"]) + return; + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + [query updateMamArchivePrefDefault:preference]; + [self sendIq:query withHandler:$newHandler(MLIQProcessor, handleSetMamPrefs)]; +} + +-(void) getMAMPrefs +{ + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqGetType]; + [query mamArchivePref]; + [self sendIq:query withHandler:$newHandler(MLIQProcessor, handleMamPrefs)]; +} + +-(void) setMAMQueryMostRecentForContact:(MLContact*) contact before:(NSString*) uid withCompletion:(void (^)(NSArray* _Nullable, NSString* _Nullable error)) completion +{ + //the completion handler will get nil, if an error prevented us toget any messaes, an empty array, if the upper end of our archive was reached or an array + //of newly loaded mlmessages in all other cases + unsigned int __block retrievedBodies = 0; + NSMutableArray* __block pageList = [NSMutableArray new]; + void __block (^query)(NSString* before); + monal_iq_handler_t __block responseHandler; + monal_void_block_t callUI = ^{ + //if we did not retrieve any body messages we don't need to process metadata sanzas (if any), but signal we reached the end of our archive + //callUI() will only be called with retrievedBodies == 0 if we reached the upper end of our mam archive, because iq errors have already been + //handled in the iq error handler below + if(retrievedBodies == 0) + { + completion(@[], nil); + return; + } + + NSMutableArray* __block historyIdList = [NSMutableArray new]; + NSNumber* __block historyId = [NSNumber numberWithInt:[[[DataLayer sharedInstance] getSmallestHistoryId] intValue] - retrievedBodies]; + + //ignore all notifications generated while processing the queued stanzas + [MLNotificationQueue queueNotificationsInBlock:^{ + uint32_t pageNo = 0; + //iterate through all pages and their messages forward in time (pages have already been sorted forward in time internally) + DDLogDebug(@"Handling %@ mam pages...", @([pageList count])); + for(NSArray* page in [[pageList reverseObjectEnumerator] allObjects]) + { + //process received message stanzas and manipulate the db accordingly + //if a new message got added to the history db, the message processor will return a MLMessage instance containing the history id of the newly created entry + DDLogDebug(@"Handling %@ entries in mam page...", @([page count])); + uint32_t entryNo = 0; + for(NSDictionary* data in page) + { + //don't write data to our tcp stream while inside this db transaction + //(all effects to the outside world should be transactional, too) + [self freezeSendQueue]; + //process all queued mam stanzas in a dedicated db write transaction + [[DataLayer sharedInstance] createTransaction:^{ + DDLogVerbose(@"Handling mam page entry[%u(%@).%u(%@)]): %@", pageNo, @([pageList count]), entryNo, @([page count]), data); + MLMessage* msg = [MLMessageProcessor processMessage:data[@"messageNode"] andOuterMessage:data[@"outerMessageNode"] forAccount:self withHistoryId:historyId]; + DDLogVerbose(@"Got message processor result: %@", msg); + //add successfully added messages to our display list + //stanzas not transporting a body will be processed, too, but the message processor will return nil for these + if(msg != nil) + { + [historyIdList addObject:msg.messageDBId]; //we only need the history id to fetch a fresh copy later + historyId = [NSNumber numberWithInt:[historyId intValue] + 1]; //calculate next history id + } + }]; + [self unfreezeSendQueue]; //this will flush all stanzas added inside the db transaction and now waiting in the send queue + entryNo++; + } + pageNo++; + } + + //throw away all queued notifications before leaving this context + [(MLNotificationQueue*)[MLNotificationQueue currentQueue] clear]; + } onQueue:@"MLhistoryIgnoreQueue"]; + + DDLogDebug(@"collected mam:2 before-pages now contain %lu messages in summary not already in history", (unsigned long)[historyIdList count]); + MLAssert([historyIdList count] <= retrievedBodies, @"did add more messages to historydb table than bodies collected!", (@{ + @"historyIdList": historyIdList, + @"retrievedBodies": @(retrievedBodies), + })); + if([historyIdList count] < retrievedBodies) + DDLogWarn(@"Got %lu mam history messages already contained in history db, possibly ougoing messages that did not have a stanzaid yet!", (unsigned long)(retrievedBodies - [historyIdList count])); + //query db (again) for the real MLMessage to account for changes in history table by non-body metadata messages received after the body-message + completion([[DataLayer sharedInstance] messagesForHistoryIDs:historyIdList], nil); + }; + responseHandler = ^(XMPPIQ* response) { + NSMutableArray* mamPage = [self getOrderedMamPageFor:[response findFirst:@"/@id"]]; + + //count new bodies + for(NSDictionary* data in mamPage) + if([data[@"messageNode"] check:@"body#"]) + retrievedBodies++; + + //add new mam page to page list + [pageList addObject:mamPage]; + + //check if we need to load more messages + if(retrievedBodies > 25) + { + //call completion to display all messages saved in db + callUI(); + } + //query fo more messages or call completion to display all messages saved in db if we reached the end of our mam archive + else + { + //page through to get more messages (a page possibly contains fewer than 25 messages having a body) + //but because we query for 50 stanzas we could easily get more than 25 messages having a body, too + if( + ![[response findFirst:@"{urn:xmpp:mam:2}fin@complete|bool"] boolValue] && + [response check:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/first#"] + ) + { + query([response findFirst:@"{urn:xmpp:mam:2}fin/{http://jabber.org/protocol/rsm}set/first#"]); + } + else + { + DDLogDebug(@"Reached upper end of mam:2 archive, returning %lu messages to ui", (unsigned long)retrievedBodies); + //can be fewer than 25 messages because we reached the upper end of the mam archive + //even zero body-messages could be true + callUI(); + } + } + }; + query = ^(NSString* _Nullable before) { + XMPPIQ* query = [[XMPPIQ alloc] initWithType:kiqSetType]; + if(contact.isMuc) + { + if(!before) + before = [[DataLayer sharedInstance] lastStanzaIdForMuc:contact.contactJid andAccount:self.accountID]; + [query setiqTo:contact.contactJid]; + [query setMAMQueryLatestMessagesForJid:nil before:before]; + } + else + { + if(!before) + before = [[DataLayer sharedInstance] lastStanzaIdForAccount:self.accountID]; + [query setMAMQueryLatestMessagesForJid:contact.contactJid before:before]; + } + DDLogDebug(@"Loading (next) mam:2 page before: %@", before); + //we always want to use blocks here because we want to make sure we get not interrupted by an app crash/restart + //which would make us use incomplete mam pages that would produce holes in history (those are very hard to remove/fill afterwards) + [self sendIq:query withResponseHandler:responseHandler andErrorHandler:^(XMPPIQ* error) { + DDLogWarn(@"Got mam:2 before-query error, returning %lu messages to ui", (unsigned long)retrievedBodies); + if(retrievedBodies == 0) + { + //call completion with nil, if there was an error or xmpp reconnect that prevented us to get any body-messages + //but only for non-item-not-found errors (and internal-server-error errors sent by one of ejabberd or prosody instead [don't know which one it was]) + if(error == nil || ([error check:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}internal-server-error"] && [@"item-not-found" isEqualToString:[error findFirst:@"error/{urn:ietf:params:xml:ns:xmpp-stanzas}text#"]])) + completion(nil, nil); + else + completion(nil, [HelperTools extractXMPPError:error withDescription:nil]); + } + else + { + //we had an error but we did already load some body-messages --> update ui anyways + callUI(); + } + }]; + }; + query(uid); +} + +#pragma mark - MUC + +-(void) joinMuc:(NSString* _Nonnull) room +{ + [self.mucProcessor join:room]; +} + +-(void) leaveMuc:(NSString* _Nonnull) room +{ + [self.mucProcessor leave:room withBookmarksUpdate:YES keepBuddylistEntry:NO]; +} + +-(AnyPromise*) checkJidType:(NSString*) jid +{ + return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + XMPPIQ* discoInfo = [[XMPPIQ alloc] initWithType:kiqGetType]; + [discoInfo setiqTo:jid]; + [discoInfo setDiscoInfoNode]; + [self sendIq:discoInfo withResponseHandler:^(XMPPIQ* response) { + NSSet* identities = [NSSet setWithArray:[response find:@"{http://jabber.org/protocol/disco#info}query/identity@category"]]; + NSSet* features = [NSSet setWithArray:[response find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; + //check if this is an account or a muc + //this test has to come first because a gateway component may have an "account" identity while also supporintg MUC. + //usually this means that there's a bot at the component's address that facilitates registration without adhoc commands. + //the "account" jidType makes it possible to add the component as a contact. + if([identities containsObject:@"account"]) + return resolve(@"account"); + else if([identities containsObject:@"conference"] + && [features containsObject:@"http://jabber.org/protocol/muc"]) + return resolve(@"muc"); + else + return resolve(@"account"); + } andErrorHandler:^(XMPPIQ* error) { + //this means the jid is an account which can not be queried if not subscribed + if([error check:@"//error/{urn:ietf:params:xml:ns:xmpp-stanzas}service-unavailable"]) + return resolve(@"account"); + else if([error check:@"//error/{urn:ietf:params:xml:ns:xmpp-stanzas}subscription-required"]) + return resolve(@"account"); + //any other error probably means the remote server is not reachable or (even more likely) the jid is incorrect + NSString* errorDescription = [HelperTools extractXMPPError:error withDescription:NSLocalizedString(@"Unexpected error while checking type of jid:", @"")]; + DDLogError(@"checkJidType got an error, informing user: %@", errorDescription); + resolve([NSError errorWithDomain:@"Monal" code:0 userInfo:@{NSLocalizedDescriptionKey: error == nil ? NSLocalizedString(@"Unexpected error while checking type of jid, please try again", @"") : errorDescription}]); + }]; + }]; +} + +#pragma mark- XMPP add and remove contact + +-(void) removeFromRoster:(MLContact*) contact +{ + DDLogVerbose(@"Removing jid from roster: %@", contact); + + //delete contact request if it exists + [[DataLayer sharedInstance] deleteContactRequest:contact]; + + XMPPPresence* presence = [XMPPPresence new]; + [presence unsubscribeContact:contact]; + [self send:presence]; + + XMPPPresence* presence2 = [XMPPPresence new]; + [presence2 unsubscribedContact:contact]; + [self send:presence2]; + + XMPPIQ* iq = [[XMPPIQ alloc] initWithType:kiqSetType]; + [iq setRemoveFromRoster:contact]; + [self send:iq]; +} + +-(void) addToRoster:(MLContact*) contact withPreauthToken:(NSString* _Nullable) preauthToken +{ + DDLogVerbose(@"(re)adding jid to roster: %@", contact); + + //delete contact request if it exists + [[DataLayer sharedInstance] deleteContactRequest:contact]; + + XMPPPresence* acceptPresence = [XMPPPresence new]; + [acceptPresence subscribedContact:contact]; + [self send:acceptPresence]; + + XMPPPresence* subscribePresence = [XMPPPresence new]; + [subscribePresence subscribeContact:contact withPreauthToken:preauthToken]; + [self send:subscribePresence]; +} + +-(void) updateRosterItem:(MLContact*) contact withName:(NSString*) name +{ + DDLogVerbose(@"Updating roster item of jid: %@", contact.contactJid); + XMPPIQ* roster = [[XMPPIQ alloc] initWithType:kiqSetType]; + [roster setUpdateRosterItem:contact withName:name]; + //this delegate will handle errors (result responses don't include any data that could be processed and will be ignored) + [self sendIq:roster withHandler:$newHandler(MLIQProcessor, handleRoster)]; +} + +#pragma mark - account management + +-(void) createInvitationWithCompletion:(monal_id_block_t) completion +{ + XMPPIQ* iq = [[XMPPIQ alloc] initWithType:kiqSetType to:self.connectionProperties.identity.domain]; + [iq addChildNode:[[MLXMLNode alloc] initWithElement:@"command" andNamespace:@"http://jabber.org/protocol/commands" withAttributes:@{ + @"node": @"urn:xmpp:invite#invite", + @"action": @"execute", + } andChildren:@[] andData:nil]]; + [self sendIq:iq withResponseHandler:^(XMPPIQ* response) { + NSString* status = [response findFirst:@"{http://jabber.org/protocol/commands}command@status"]; + NSString* uri = [response findFirst:@"{http://jabber.org/protocol/commands}command/\\[0]@uri\\"]; + NSString* landing = [response findFirst:@"{http://jabber.org/protocol/commands}command/\\[0]@landing-url\\"]; + NSDate* expires = [response findFirst:@"{http://jabber.org/protocol/commands}command/\\[0]@expire\\|datetime"]; + //at least yax.im does not implement the dataform depicted in XEP-0401 example 4 (dataform with wrapper) + if(uri == nil) + { + uri = [response findFirst:@"{http://jabber.org/protocol/commands}command/\\@uri\\"]; + landing = [response findFirst:@"{http://jabber.org/protocol/commands}command/\\@landing-url\\"]; + expires = [response findFirst:@"{http://jabber.org/protocol/commands}command/\\@expire\\|datetime"]; + } + if([@"completed" isEqualToString:status] && uri != nil) + { + if(landing == nil) + landing = [NSString stringWithFormat:@"https://invite.monal-im.org/#%@", uri]; + completion(@{ + @"success": @YES, + @"uri": uri, + @"landing": landing, + @"expires": nilWrapper(expires), + }); + } + else + completion(@{ + @"success": @NO, + @"error": [NSString stringWithFormat:NSLocalizedString(@"Failed to create invitation, unknown error: %@", @""), status], + }); + } andErrorHandler:^(XMPPIQ* error) { + completion(@{ + @"success": @NO, + @"error": [HelperTools extractXMPPError:error withDescription:@"Failed to create invitation"], + }); + }]; +} + +-(void) changePassword:(NSString *) newPass withCompletion:(xmppCompletion) completion +{ + XMPPIQ* iq = [[XMPPIQ alloc] initWithType:kiqSetType]; + [iq setiqTo:self.connectionProperties.identity.domain]; + [iq changePasswordForUser:self.connectionProperties.identity.user newPassword:newPass]; + [self sendIq:iq withResponseHandler:^(XMPPIQ* response __unused) { + //dispatch completion handler outside of the receiveQueue + if(completion) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + completion(YES, @""); + }); + } andErrorHandler:^(XMPPIQ* error) { + //dispatch completion handler outside of the receiveQueue + if(completion) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + completion(NO, error ? [HelperTools extractXMPPError:error withDescription:NSLocalizedString(@"Could not change password", @"")] : NSLocalizedString(@"Could not change password: your account is currently not connected", @"")); + }); + }]; +} + +-(void) requestRegFormWithToken:(NSString* _Nullable) token andCompletion:(xmppDataCompletion) completion andErrorCompletion:(xmppCompletion) errorCompletion +{ + //this is a registration request + _registration = YES; + _registrationSubmission = NO; + _registrationToken = token; + _regFormCompletion = completion; + _regFormErrorCompletion = errorCompletion; + [self connect]; +} + +-(void) registerUser:(NSString*) username withPassword:(NSString*) password captcha:(NSString* _Nullable) captcha andHiddenFields:(NSDictionary* _Nullable) hiddenFields withCompletion:(xmppCompletion) completion +{ + //this is a registration submission + _registration = NO; + _registrationSubmission = YES; + self.regUser = username; + self.regPass = password; + self.regCode = captcha; + self.regHidden = hiddenFields; + _regFormSubmitCompletion = completion; + if(_accountState < kStateHasStream) + [self connect]; + else + { + DDLogInfo(@"Registration: Calling submitRegForm"); + [self submitRegForm]; + } +} + +-(void) submitRegToken:(NSString*) token +{ + XMPPIQ* iq = [[XMPPIQ alloc] initWithType:kiqSetType]; + [iq setiqTo:self.connectionProperties.identity.domain]; + [iq submitRegToken:token]; + + [self sendIq:iq withResponseHandler:^(XMPPIQ* result __unused) { + DDLogInfo(@"Registration: Calling requestRegForm from submitRegToken handler"); + [self requestRegForm]; + } andErrorHandler:^(XMPPIQ* error) { + //dispatch completion handler outside of the receiveQueue + if(self->_regFormErrorCompletion) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + self->_regFormErrorCompletion(NO, [HelperTools extractXMPPError:error withDescription:@"Could not submit registration token"]); + }); + }]; +} + +-(void) requestRegForm +{ + XMPPIQ* iq = [[XMPPIQ alloc] initWithType:kiqGetType]; + [iq setiqTo:self.connectionProperties.identity.domain]; + [iq getRegistrationFields]; + + [self sendIq:iq withResponseHandler:^(XMPPIQ* result) { + if(!( + ([result check:@"{jabber:iq:register}query/username"] && [result check:@"{jabber:iq:register}query/password"]) || + [result check:@"{jabber:iq:register}query/\\{jabber:iq:register}form\\"] || + [result check:@"{jabber:iq:register}query/\\{urn:xmpp:captcha}form\\"] + )) + { + //dispatch completion handler outside of the receiveQueue + if(self->_regFormErrorCompletion) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + if([result check:@"{jabber:iq:register}query/instructions"]) + self->_regFormErrorCompletion(NO, [NSString stringWithFormat:@"Could not request registration form: %@", [result findFirst:@"{jabber:iq:register}query/instructions#"]]); + else + self->_regFormErrorCompletion(NO, @"Could not request registration form: unknown error"); + }); + return; + } + //dispatch completion handler outside of the receiveQueue + if(self->_regFormCompletion) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSMutableDictionary* hiddenFormFields = nil; + if([result check:@"{jabber:iq:register}query/{jabber:x:data}x/field"]) + { + hiddenFormFields = [NSMutableDictionary new]; + for(MLXMLNode* field in [result find:@"{jabber:iq:register}query/{jabber:x:data}x/field"]) + hiddenFormFields[[field findFirst:@"/@var"]] = [field findFirst:@"value#"]; + } + self->_regFormCompletion([result findFirst:@"{jabber:iq:register}query/{urn:xmpp:bob}data#|base64"], hiddenFormFields); + }); + } andErrorHandler:^(XMPPIQ* error) { + //dispatch completion handler outside of the receiveQueue + if(self->_regFormErrorCompletion) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + self->_regFormErrorCompletion(NO, [HelperTools extractXMPPError:error withDescription:@"Could not request registration form"]); + }); + }]; +} + +-(void) submitRegForm +{ + XMPPIQ* iq = [[XMPPIQ alloc] initWithType:kiqSetType]; + [iq registerUser:self.regUser withPassword:self.regPass captcha:self.regCode andHiddenFields:self.regHidden]; + + [self sendIq:iq withResponseHandler:^(XMPPIQ* result __unused) { + //dispatch completion handler outside of the receiveQueue + if(self->_regFormSubmitCompletion) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + self->_regFormSubmitCompletion(YES, nil); + }); + } andErrorHandler:^(XMPPIQ* error) { + //dispatch completion handler outside of the receiveQueue + if(self->_regFormSubmitCompletion) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + self->_regFormSubmitCompletion(NO, [HelperTools extractXMPPError:error withDescription:@"Could not submit registration form"]); + }); + }]; +} + +#pragma mark - nsstream delegate + +-(void)stream:(NSStream*) stream handleEvent:(NSStreamEvent) eventCode +{ + DDLogDebug(@"Stream %@ has event %lu", stream, (unsigned long)eventCode); + switch(eventCode) + { + case NSStreamEventOpenCompleted: + { + DDLogVerbose(@"Stream %@ open completed", stream); + //reset _streamHasSpace to its default value until the fist NSStreamEventHasSpaceAvailable event occurs + if(stream == _oStream) + { + self->_streamHasSpace = NO; + + //restart logintimer when our output stream becomes readable (don't do anything without a running timer) + if(_loginTimer != nil && self->_accountState < kStateLoggedIn) + [self reinitLoginTimer]; + + //we want this to be sync instead of async to make sure we are in kStateConnected before sending anything + [self dispatchOnReceiveQueue:^{ + self->_accountState = kStateConnected; + if(self->_blockToCallOnTCPOpen != nil) + { + self->_blockToCallOnTCPOpen(); + self->_blockToCallOnTCPOpen = nil; //don't call this twice + } + }]; + } + break; + } + + //for writing + case NSStreamEventHasSpaceAvailable: + { + if(stream != _oStream) + { + DDLogDebug(@"Ignoring NSStreamEventHasSpaceAvailable event on wrong stream %@", stream); + break; + } + [_sendQueue addOperationWithBlock: ^{ + DDLogVerbose(@"Stream %@ has space to write", stream); + self->_streamHasSpace=YES; + [self writeFromQueue]; + }]; + break; + } + + //for reading + case NSStreamEventHasBytesAvailable: + { + DDLogError(@"Stream %@ has bytes to read (should not be called!)", stream); + break; + } + + case NSStreamEventErrorOccurred: + { + NSError* st_error = [stream streamError]; + DDLogError(@"Stream %@ error code=%ld domain=%@ local desc:%@", stream, (long)st_error.code,st_error.domain, st_error.localizedDescription); + /* + if(stream != _oStream) //check for _oStream here, because we don't have any _iStream (the mlpipe input stream was directly handed over to the xml parser) + { + DDLogInfo(@"Ignoring error in iStream (will already be handled in oStream error handler"); + break; + } + */ + + //check accountState to make sure we don't swallow any errors thrown while [self connect] was already called, + //but the _reconnectInProgress flag not reset yet + if(_reconnectInProgress && self.accountState_timersToCancelOnDisconnect) { + [self->_timersToCancelOnDisconnect addObject:createTimer(1.0, (^{ + //add this to parseQueue to make sure we completely handle everything that came in before the connection was closed, before handling the close event itself + [self->_parseQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + DDLogInfo(@"Inside parseQueue: %@ Stream %@ encountered eof, trying to reconnect", [stream class], stream); + [self reconnect]; + }]] waitUntilFinished:NO]; + }))]; + } + break; + } + } +} + +#pragma mark network I/O + +-(void) writeFromQueue +{ + if(!_streamHasSpace) + { + DDLogVerbose(@"no space to write. early return from writeFromQueue()."); + return; + } + if(![_outputQueue count]) + { + DDLogVerbose(@"no entries in _outputQueue. trying to send half-sent data."); + [self writeToStream:nil]; + DDLogVerbose(@"no entries in _outputQueue. early return from writeFromQueue()."); + return; + } + BOOL requestAck=NO; + NSMutableArray* queueCopy = [[NSMutableArray alloc] initWithArray:_outputQueue]; + DDLogVerbose(@"iterating _outputQueue"); + for(id entry in queueCopy) + { + BOOL success = NO; + NSString* entryType = @"unknown"; + if([entry isKindOfClass:[MLXMLNode class]]) + { + entryType = @"MLXMLNode"; + MLXMLNode* node = (MLXMLNode*)entry; + success = [self writeToStream:node.XMLString]; + if(success) + { + //only react to stanzas, not nonzas + if([node.element isEqualToString:@"iq"] + || [node.element isEqualToString:@"message"] + || [node.element isEqualToString:@"presence"]) { + requestAck=YES; + } + } + } + else + { + entryType = @"NSString"; + success = [self writeToStream:entry]; + } + + if(success) + { + DDLogVerbose(@"removing sent %@ entry from _outputQueue", entryType); + [_outputQueue removeObject:entry]; + } + else //stop sending the remainder of the queue if the send failed (tcp output buffer full etc.) + { + DDLogInfo(@"could not send whole _outputQueue: tcp buffer full or connection has an error"); + break; + } + } + + //restart logintimer for new write to our stream while not logged in (don't do anything without a running timer) + if(_loginTimer != nil && self->_accountState < kStateLoggedIn) + [self reinitLoginTimer]; + + if(requestAck) + { + //adding the smacks request to the parseQueue will make sure that we send the request + //*after* processing an incoming burst of stanzas (which is potentially causing an outgoing burst of stanzas) + //this reduces the requests to an absolute minimum while still maintaining the rule to request an ack + //for every stanza (e.g. until the smacks queue is empty) and not sending an ack if one is already in flight + if(_accountState>=kStateBound) + [_parseQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + [self requestSMAck:NO]; + }]] waitUntilFinished:NO]; + else + DDLogWarn(@"no xmpp resource bound, not calling requestSMAck"); + } + else + DDLogVerbose(@"NOT calling requestSMAck..."); +} + +-(BOOL) writeToStream:(NSString*) messageOut +{ + if(!_streamHasSpace) + { + DDLogVerbose(@"no space to write. returning."); + return NO; //no space to write --> stanza has to remain in _outputQueue + } + if(!_oStream) + { + DDLogVerbose(@"no stream to write. returning."); + return NO; //no stream to write --> stanza has to remain in _outputQueue and get dropped later on + } + + //try to send remaining buffered data first + if(_outputBufferByteCount > 0) + { + DDLogVerbose(@"sending remaining bytes in outputBuffer: %lu", (unsigned long)_outputBufferByteCount); + NSInteger sentLen = [_oStream write:_outputBuffer maxLength:_outputBufferByteCount]; + if(sentLen > 0) + { + if((NSUInteger)sentLen != _outputBufferByteCount) //some bytes remaining to send --> trim buffer and return NO + { + DDLogVerbose(@"could not send all bytes in outputBuffer: %lu of %lu sent, %lu remaining", (unsigned long)sentLen, (unsigned long)_outputBufferByteCount, (unsigned long)(_outputBufferByteCount-sentLen)); + memmove(_outputBuffer, _outputBuffer+(size_t)sentLen, _outputBufferByteCount-(size_t)sentLen); + _outputBufferByteCount-=sentLen; + _streamHasSpace=NO; + return NO; //stanza has to remain in _outputQueue + } + else + { + DDLogVerbose(@"managed to send whole outputBuffer: %lu bytes", (unsigned long)sentLen); + //dealloc empty buffer + free(_outputBuffer); + _outputBuffer=nil; + _outputBufferByteCount=0; //everything sent + } + } + else + { + NSError* error = [_oStream streamError]; + DDLogError(@"sending: failed with error %ld domain %@ message %@", (long)error.code, error.domain, error.userInfo); + //reconnect from third party queue to not block send queue + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ + [self reconnect]; + }); + return NO; + } + } + + //then try to send the stanza in question and buffer half sent data + if(!messageOut) + { + DDLogInfo(@"tried to send empty message. returning without doing anything."); + return YES; //pretend we sent the empty "data" + } + const uint8_t* rawstring = (const uint8_t *)[messageOut UTF8String]; + NSInteger rawstringLen = strlen((char*)rawstring); + if(rawstringLen <= 0) + return YES; //pretend we sent the empty "data" + NSInteger sentLen = [_oStream write:rawstring maxLength:rawstringLen]; + if(sentLen!=-1) + { + if(sentLen!=rawstringLen) + { + DDLogVerbose(@"could not send all bytes of outgoing stanza: %lu of %lu sent, %lu remaining", (unsigned long)sentLen, (unsigned long)rawstringLen, (unsigned long)(rawstringLen-sentLen)); + //allocate new _outputBuffer + _outputBuffer=malloc(sizeof(uint8_t) * (rawstringLen-sentLen)); + if(_outputBuffer == NULL) + { + [NSException raise:@"NSInternalInconsistencyException" format:@"failed malloc" arguments:nil]; + return NO; //since the stanza was partially written, neither YES nor NO as return value will result in a consistent state + } + //copy the remaining data into the buffer and set the buffer pointer accordingly + memcpy(_outputBuffer, rawstring+(size_t)sentLen, (size_t)(rawstringLen-sentLen)); + _outputBufferByteCount=(size_t)(rawstringLen-sentLen); + _streamHasSpace=NO; + } + else + { + DDLogVerbose(@"managed to send whole outgoing stanza: %lu bytes", (unsigned long)sentLen); + _outputBufferByteCount=0; + } + return YES; + } + else + { + NSError* error = [_oStream streamError]; + DDLogError(@"sending: failed with error %ld domain %@ message %@", (long)error.code, error.domain, error.userInfo); + //reconnect from third party queue to not block send queue + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ + [self reconnect]; + }); + return NO; + } +} + +#pragma mark misc + +-(void) enablePush +{ +#if TARGET_OS_SIMULATOR + DDLogError(@"Not registering push on the simulator!"); + [self disablePush]; +#else + NSString* pushToken = [MLXMPPManager sharedInstance].pushToken; + NSString* selectedPushServer = [[HelperTools defaultsDB] objectForKey:@"selectedPushServer"]; + if(pushToken == nil || [pushToken length] == 0 || selectedPushServer == nil || self.accountState < kStateBound) + { + DDLogInfo(@"NOT registering and enabling push: %@ token: %@ (accountState: %ld, supportsPush: %@)", selectedPushServer, pushToken, (long)self.accountState, bool2str([self.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:push:0"])); + return; + } + if([MLXMPPManager sharedInstance].hasAPNSToken) + { + BOOL needsDeregister = false; + // check if the currently used push server is an old server that should no longer be used + if([[HelperTools getInvalidPushServers] objectForKey:selectedPushServer] != nil) + { + needsDeregister = YES; + DDLogInfo(@"Selecting new push server because the previous is a legacy server"); + // select new pushserver + NSString* newPushServer = [HelperTools getSelectedPushServerBasedOnLocale]; + [[HelperTools defaultsDB] setObject:newPushServer forKey:@"selectedPushServer"]; + selectedPushServer = newPushServer; + } + // check if the last used push server (db) matches the currently selected server + NSString* lastUsedPushServer = [[DataLayer sharedInstance] lastUsedPushServerForAccount:self.accountID]; + if([lastUsedPushServer isEqualToString:selectedPushServer] == NO) + [self disablePushOnOldAndAdditionalServers:lastUsedPushServer]; + else if(needsDeregister) + [self disablePushOnOldAndAdditionalServers:nil]; + // push is now disabled on the existing server + // enable push + XMPPIQ* enablePushIq = [[XMPPIQ alloc] initWithType:kiqSetType]; + [enablePushIq setPushEnableWithNode:pushToken onAppserver:selectedPushServer]; + [self sendIq:enablePushIq withHandler:$newHandler(MLIQProcessor, handlePushEnabled, $ID(selectedPushServer))]; + } + else // [MLXMPPManager sharedInstance].hasAPNSToken == NO + { + if([self.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:push:0"]) + { + //disable push for this node + [self disablePush]; + } + } +#endif +} + +-(void) disablePush +{ + DDLogVerbose(@"Trying to disable push on account: %@", self.accountID); + NSString* pushToken = [[HelperTools defaultsDB] objectForKey:@"pushToken"]; + NSString* pushServer = [[DataLayer sharedInstance] lastUsedPushServerForAccount:self.accountID]; + if(pushToken == nil || pushServer == nil) + { + return; + } + DDLogInfo(@"DISABLING push token %@ on server %@ (accountState: %ld, supportsPush: %@)", pushToken, pushServer, (long)self.accountState, bool2str([self.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:push:0"])); + XMPPIQ* disable = [[XMPPIQ alloc] initWithType:kiqSetType]; + [disable setPushDisable:pushToken onPushServer:pushServer]; + [self send:disable]; +} + +-(void) disablePushOnOldAndAdditionalServers:(NSString*) additionalServer +{ + // Disable push on old / legacy servers + NSDictionary* oldServers = [HelperTools getInvalidPushServers]; + for(NSString* server in oldServers) + { + DDLogInfo(@"Disabling push on old pushserver: %@", server); + XMPPIQ* disable = [[XMPPIQ alloc] initWithType:kiqSetType]; + NSString* pushNode = nilExtractor([oldServers objectForKey:server]); + //use push token if the push node is nil (e.g. for fpush based servers) + if(pushNode == nil) + pushNode = [MLXMPPManager sharedInstance].pushToken; + [disable setPushDisable:pushNode onPushServer:server]; + [self send:disable]; + } + // disable push on the last used server + if(additionalServer != nil && [MLXMPPManager sharedInstance].pushToken != nil) + { + DDLogInfo(@"Disabling push on last used pushserver: %@", additionalServer); + XMPPIQ* disable = [[XMPPIQ alloc] initWithType:kiqSetType]; + [disable setPushDisable:[MLXMPPManager sharedInstance].pushToken onPushServer:additionalServer]; + [self send:disable]; + } + // disable push on all non selected available push servers + NSString* selectedNewPushServer = [[HelperTools defaultsDB] objectForKey:@"selectedPushServer"]; + for(NSString* availServer in [HelperTools getAvailablePushServers]) + { + if([availServer isEqualToString:selectedNewPushServer] == YES) + continue; + XMPPIQ* disable = [[XMPPIQ alloc] initWithType:kiqSetType]; + [disable setPushDisable:[MLXMPPManager sharedInstance].pushToken onPushServer:availServer]; + [self send:disable]; + } +} + +-(void) updateIqHandlerTimeouts +{ + //only handle iq timeouts while the parseQueue is almost empty + //(a long backlog in the parse queue could trigger spurious iq timeouts for iqs we already received an answer to, but didn't process it yet) + if([_parseQueue operationCount] > 4 || _accountState < kStateBound || !_catchupDone) + return; + + //update idle timers, too + [[DataLayer sharedInstance] decrementIdleTimersForAccount:self]; + + //update iq handlers + BOOL stateUpdated = NO; + @synchronized(_iqHandlers) { + //we are NOT mutating on iteration here, because we use dispatchAsyncOnReceiveQueue to handle timeouts + NSMutableArray* idsToRemove = [NSMutableArray new]; + for(NSString* iqid in _iqHandlers) + { + //decrement handler timeout every second and check if it landed below zero --> trigger a fake iq error to handle timeout + //this makes sure a freeze/killed app doesn't immediately trigger timeouts once the app is restarted, as it would be with timestamp based timeouts + //doing it this way makes sure the incoming iq result has a chance to be processed even in a freeze/kill scenario + _iqHandlers[iqid][@"timeout"] = @([_iqHandlers[iqid][@"timeout"] doubleValue] - 1.0); + if([_iqHandlers[iqid][@"timeout"] doubleValue] < 0.0) + { + DDLogWarn(@"%@: Timeout of handler triggered: %@", _logtag, _iqHandlers[iqid]); + //only force save state after calling a handler + //(timeout changes that don't make it to disk only extend the timeout by a few seconds but don't have any negative sideeffect) + stateUpdated = YES; + + //fake xmpp stanza error to make timeout handling transparent without the need for invalidation handler + //we need to fake the from, too (no from means own bare jid) + XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:_iqHandlers[iqid][@"iq"]]; + errorIq.to = self.connectionProperties.identity.fullJid; + if([_iqHandlers[iqid][@"iq"] to] != nil) + errorIq.from = [_iqHandlers[iqid][@"iq"] to]; + else + errorIq.from = self.connectionProperties.identity.jid; + [errorIq addChildNode:[[MLXMLNode alloc] initWithElement:@"error" withAttributes:@{@"type": @"wait"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"remote-server-timeout" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas"], + [[MLXMLNode alloc] initWithElement:@"text" andNamespace:@"urn:ietf:params:xml:ns:xmpp-stanzas" withAttributes:@{} andChildren:@[] andData:[NSString stringWithFormat:@"No response in %d seconds", (int)IQ_TIMEOUT]], + ] andData:nil]]; + + //make sure our fake error iq is handled inside the receiveQueue + //extract this from _iqHandlers to make sure we only handle iqs that didn't get handled in the meantime + NSMutableDictionary* iqHandler = self->_iqHandlers[iqid]; + [idsToRemove addObject:iqid]; + if(iqHandler) + { + //do a real async dispatch, not an automatic sync one because we are in the same queue + [_receiveQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + //make sure these handlers are called inside a db write transaction just like receiving a real error iq + //--> don't create a deadlock with 2 threads waiting for db write transaction and synchronized iqhandlers + // in opposite order + [[DataLayer sharedInstance] createTransaction:^{ + DDLogDebug(@"Calling iq handler with faked error iq: %@", errorIq); + if(iqHandler[@"handler"] != nil) + $call(iqHandler[@"handler"], $ID(account, self), $ID(iqNode, errorIq)); + else if(iqHandler[@"errorHandler"] != nil) + ((monal_iq_handler_t) iqHandler[@"errorHandler"])(errorIq); + }]; + }]] waitUntilFinished:NO]; + } + else + DDLogError(@"%@: iq handler for '%@' vanished while switching to receive queue", _logtag, iqid); + } + } + //now delete iqs marked for deletion + for(NSString* iqid in idsToRemove) + [_iqHandlers removeObjectForKey:iqid]; + } + + //make sure all state is persisted as soon as possible (we could have called handlers and we don't want to execute them twice!) + if(stateUpdated) + [self persistState]; +} + +-(void) delayIncomingMessageStanzasForArchiveJid:(NSString*) archiveJid +{ + _inCatchup[archiveJid] = @YES; //catchup not done and replay not finished +} + +-(void) delayIncomingMessageStanzaUntilCatchupDone:(XMPPMessage*) originalParsedStanza +{ + NSString* archiveJid = self.connectionProperties.identity.jid; + if([[originalParsedStanza findFirst:@"/@type"] isEqualToString:@"groupchat"]) + archiveJid = originalParsedStanza.fromUser; + + [[DataLayer sharedInstance] addDelayedMessageStanza:originalParsedStanza forArchiveJid:archiveJid andAccountID:self.accountID]; +} + +//this method is needed to not have a retain cycle (happens when using a block instead of this method in mamFinishedFor:) +-(void) _handleInternalMamFinishedFor:(NSString*) archiveJid +{ + if(self.accountState < kStateBound) + { + DDLogWarn(@"Aborting delayed replay because not >= kStateBound anymore! Remaining stanzas will be kept in DB and be handled after next smacks reconnect."); + return; + } + + [MLNotificationQueue queueNotificationsInBlock:^{ + DDLogVerbose(@"Creating db transaction for delayed stanza handling of jid %@", archiveJid); + [[DataLayer sharedInstance] createTransaction:^{ + //don't write data to our tcp stream while inside this db transaction (all effects to the outside world should be transactional, too) + [self freezeSendQueue]; + //pick the next delayed message stanza (will return nil if there isn't any left) + MLXMLNode* delayedStanza = [[DataLayer sharedInstance] getNextDelayedMessageStanzaForArchiveJid:archiveJid andAccountID:self.accountID]; + DDLogDebug(@"Got delayed stanza: %@", delayedStanza); + if(delayedStanza == nil) + { + DDLogInfo(@"Catchup finished for jid %@", archiveJid); + [self->_inCatchup removeObjectForKey:archiveJid]; //catchup done and replay finished + + //handle cached mds data for this jid + if(self->_mdsData[archiveJid] != nil) + [self handleMdsData:self->_mdsData[archiveJid] forJid:archiveJid]; + + //handle old mamFinished code as soon as all delayed messages have been processed + //we need to wait for all delayed messages because at least omemo needs the pep headline messages coming in during mam catchup + if([self.connectionProperties.identity.jid isEqualToString:archiveJid]) + { + if(!self->_catchupDone) + { + DDLogVerbose(@"Now posting kMonalFinishedCatchup notification"); + [self handleFinishedCatchup]; + } + } + } + else + { + //now *really* process delayed message stanza + [self processInput:delayedStanza withDelayedReplay:YES]; + + DDLogDebug(@"Delayed Stanza finished processing: %@", delayedStanza); + + //add async processing of next delayed message stanza to receiveQueue + //the async dispatching makes it possible to abort the replay by pushing a disconnect block etc. onto the receieve queue + //and makes sure we process every delayed stanza in its own receive queue operation and its own db transaction + [self->_receiveQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + [self _handleInternalMamFinishedFor:archiveJid]; + }]] waitUntilFinished:NO]; + } + }]; + DDLogVerbose(@"Transaction for delayed stanza handling for jid %@ ended", archiveJid); + [self unfreezeSendQueue]; //this will flush all stanzas added inside the db transaction and now waiting in the send queue + } onQueue:@"delayedStanzaReplay"]; + [self persistState]; //make sure to persist all state changes triggered by the events in the notification queue +} + +-(void) mamFinishedFor:(NSString*) archiveJid +{ + //we should be already in the receive queue, but just to make sure (sync dispatch will do nothing if we already are in the right queue) + [self dispatchOnReceiveQueue:^{ + self->_inCatchup[archiveJid] = @NO; //catchup done, but replay not finished + //handle delayed message stanzas delivered while the mam catchup was in progress + //the first call and all subsequent self-invocations are handled by dispatching it async to the receiveQueue + //the async dispatching makes it possible to abort the replay by pushing a disconnect block etc. onto the receieve queue + //and makes sure we process every delayed stanza in its own receive queue operation and its own db transaction + [self->_receiveQueue addOperations:@[[NSBlockOperation blockOperationWithBlock:^{ + [self _handleInternalMamFinishedFor:archiveJid]; + }]] waitUntilFinished:NO]; + }]; +} + +-(void) initCatchupStats +{ + self->_catchupStanzaCounter = 0; + self->_catchupStartTime = [NSDate date]; +} + +-(void) logCatchupStats +{ + if(self->_catchupStartTime != nil) + { + NSDate* now = [NSDate date]; + DDLogInfo(@"Handled %u stanzas in %f seconds...", self->_catchupStanzaCounter, [now timeIntervalSinceDate:self->_catchupStartTime]); + } +} + +-(void) handleFinishedCatchup +{ + self->_catchupDone = YES; + self.isDoingFullReconnect = !self.connectionProperties.supportsSM3; + + //log catchup statistics + [self logCatchupStats]; + + //call all reconnection handlers and clear them afterwards + @synchronized(_reconnectionHandlers) { + NSArray* handlers = [_reconnectionHandlers copy]; + [_reconnectionHandlers removeAllObjects]; + for(MLHandler* handler in handlers) + $call(handler, $ID(account, self)); + } + [self persistState]; + + //don't queue this notification because it should be handled INLINE inside the receive queue + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalFinishedCatchup object:self userInfo:nil]; +} + +-(void) updateMdsData:(NSDictionary*) mdsData +{ + for(NSString* jid in mdsData) + { + //update cached data + _mdsData[jid] = mdsData[jid]; + + //handle mds update directly, if not in catchup for this jid + //everything else will be handled once the catchup is finished + NSString* catchupJid = self.connectionProperties.identity.jid; + if([[DataLayer sharedInstance] isBuddyMuc:jid forAccount:self.accountID]) + catchupJid = jid; + if(_inCatchup[catchupJid] == nil && _mdsData[jid] != nil) + [self handleMdsData:_mdsData[jid] forJid:jid]; + } +} + +-(void) handleMdsData:(MLXMLNode*) data forJid:(NSString*) jid +{ + NSString* stanzaId = [data findFirst:@"{urn:xmpp:mds:displayed:0}displayed/{urn:xmpp:sid:0}stanza-id@id"]; + NSString* by = [data findFirst:@"{urn:xmpp:mds:displayed:0}displayed/{urn:xmpp:sid:0}stanza-id@by"]; + DDLogInfo(@"Got mds displayed element for chat %@ by %@: %@", jid, by, stanzaId); + + if([[DataLayer sharedInstance] isBuddyMuc:jid forAccount:self.accountID]) + { + if(![jid isEqualToString:by]) + { + DDLogWarn(@"Mds stanza-id by not equal to muc jid, ignoring!"); + return; + } + + //NSString* ownNick = [[DataLayer sharedInstance] ownNickNameforMuc:jid forAccount:self.accountID] + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:jid andAccount:self.accountID tillStanzaId:stanzaId wasOutgoing:NO]; + DDLogDebug(@"Muc marked as read: %@", unread); + + //remove notifications of all remotely read messages (indicated by sending a display marker) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:self userInfo:@{@"messagesArray":unread}]; + + //update unread count in active chats list + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self userInfo:@{ + @"contact": [MLContact createContactFromJid:jid andAccountID:self.accountID] + }]; + } + else + { + if(![self.connectionProperties.identity.jid isEqualToString:by]) + { + DDLogWarn(@"Mds stanza-id by not equal to own bare jid, ignoring!"); + return; + } + + NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:jid andAccount:self.accountID tillStanzaId:stanzaId wasOutgoing:NO]; + DDLogDebug(@"1:1 marked as read: %@", unread); + + //remove notifications of all remotely read messages (indicated by sending a display marker) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:self userInfo:@{@"messagesArray":unread}]; + + //update unread count in active chats list + [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self userInfo:@{ + @"contact": [MLContact createContactFromJid:jid andAccountID:self.accountID] + }]; + } +} + +-(void) addMessageToMamPageArray:(NSDictionary*) messageDictionary +{ + @synchronized(_mamPageArrays) { + if(!_mamPageArrays[[messageDictionary[@"outerMessageNode"] findFirst:@"{urn:xmpp:mam:2}result@queryid"]]) + _mamPageArrays[[messageDictionary[@"outerMessageNode"] findFirst:@"{urn:xmpp:mam:2}result@queryid"]] = [NSMutableArray new]; + [_mamPageArrays[[messageDictionary[@"outerMessageNode"] findFirst:@"{urn:xmpp:mam:2}result@queryid"]] addObject:messageDictionary]; + } +} + +-(NSMutableArray*) getOrderedMamPageFor:(NSString*) mamQueryId +{ + NSMutableArray* array; + @synchronized(_mamPageArrays) { + if(_mamPageArrays[mamQueryId] == nil) + return [NSMutableArray new]; //return empty array if nothing can be found (after app crash etc.) + array = _mamPageArrays[mamQueryId]; + [_mamPageArrays removeObjectForKey:mamQueryId]; + } + return array; +} + +-(void) publishMDSMarkerForMessage:(MLMessage*) msg +{ + NSString* max_items = @"255"; //fallback for servers not supporting "max" + if(self.connectionProperties.supportsPubSubMax) + max_items = @"max"; + [self.pubsub publishItem:[[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{kId: msg.buddyName} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"displayed" andNamespace:@"urn:xmpp:mds:displayed:0" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"stanza-id" andNamespace:@"urn:xmpp:sid:0" withAttributes:@{ + @"by": msg.isMuc ? msg.buddyName : self.connectionProperties.identity.jid, + @"id": msg.stanzaId, + } andChildren:@[] andData:nil] + ] andData:nil] + ] andData:nil] onNode:@"urn:xmpp:mds:displayed:0" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"whitelist", + @"pubsub#max_items": max_items, + @"pubsub#send_last_published_item": @"never", + }]; +} + +-(void) sendDisplayMarkerForMessages:(NSArray*) unread +{ + //ignore empty arrays + if(unread.count == 0) + return; + + //send displayed marker for last unread message *marked as wanting chat markers* (XEP-0333) + MLMessage* lastMarkableMessage = nil; + for(MLMessage* msg in unread) + if(msg.displayMarkerWanted) + lastMarkableMessage = msg; + + //last unread message used for mds + MLMessage* lastUnreadMessage = [unread lastObject]; + + if(![[HelperTools defaultsDB] boolForKey:@"SendDisplayedMarkers"]) + { + DDLogVerbose(@"Not sending chat marker, configured to not do so..."); + [self publishMDSMarkerForMessage:lastUnreadMessage]; //always publish mds marker + return; + } + + //don't send chatmarkers in channels (all messages have the same muc attributes, randomly pick the last one) + if(lastUnreadMessage.isMuc && [kMucTypeChannel isEqualToString:lastUnreadMessage.mucType]) + { + DDLogVerbose(@"Not sending XEP-0333 chat marker in channel..."); + [self publishMDSMarkerForMessage:lastUnreadMessage]; //always publish mds marker + return; + } + + //all messages have the same contact, randomly pick the last one + MLContact* contact = [MLContact createContactFromJid:lastUnreadMessage.buddyName andAccountID:lastUnreadMessage.accountID]; + //don't send chatmarkers to 1:1 chats with users in our contact list that did not subscribe us (e.g. are not allowed to see us) + if(!contact.isMuc && !contact.isSubscribedFrom) + { + DDLogVerbose(@"Not sending chat marker, we are not subscribed from this contact..."); + [self publishMDSMarkerForMessage:lastUnreadMessage]; //always publish mds marker + return; + } + + //only send chatmarkers if requested by contact + BOOL assistedMDS = [self.connectionProperties.accountDiscoFeatures containsObject:@"urn:xmpp:mds:server-assist:0"] && lastMarkableMessage == lastUnreadMessage; + if(lastMarkableMessage != nil) + { + XMPPMessage* displayedNode = [[XMPPMessage alloc] initToContact:contact]; + [displayedNode setDisplayed:lastMarkableMessage.isMuc && lastMarkableMessage.stanzaId != nil ? lastMarkableMessage.stanzaId : lastMarkableMessage.messageId]; + if(assistedMDS) + [displayedNode setMDSDisplayed:lastMarkableMessage.stanzaId withStanzaIdBy:(lastMarkableMessage.isMuc ? lastMarkableMessage.buddyName : self.connectionProperties.identity.jid)]; + [displayedNode setStoreHint]; + DDLogVerbose(@"Sending display marker: %@", displayedNode); + [self send:displayedNode]; + } + + //send mds if not already done by server using mds-assist + if(!assistedMDS) + [self publishMDSMarkerForMessage:lastUnreadMessage]; //always publish mds marker +} + +-(void) removeFromServerWithCompletion:(void (^)(NSString* _Nullable error)) completion +{ + XMPPIQ* remove = [[XMPPIQ alloc] initWithType:kiqSetType]; + [remove addChildNode:[[MLXMLNode alloc] initWithElement:@"query" andNamespace:@"jabber:iq:register" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"remove"] + ] andData:nil]]; + [self sendIq:remove withResponseHandler:^(XMPPIQ* result) { + //disconnect account and throw away everything waiting to be processed + //(for example the stream close coming from the server after removing the account on the server) + [self disconnect:YES]; //this disconnect is needed to not show spurious errors on delete (technically the explicitLogout is not needed, but it doesn't hurt either) + [[MLXMPPManager sharedInstance] removeAccountForAccountID:self.accountID]; + completion(nil); //signal success to UI + } andErrorHandler:^(XMPPIQ* error) { + if(error != nil) //don't report iq invalidation on disconnect as error + { + NSString* errorStr = [HelperTools extractXMPPError:error withDescription:NSLocalizedString(@"Server does not support account removal", @"")]; + completion(errorStr); //signal error to UI + } + }]; +} + +-(void) markCapsQueryCompleteFor:(NSString*) ver +{ + [_runningCapsQueries removeObject:ver]; +} + +-(void) publishRosterName:(NSString* _Nullable) rosterName +{ + DDLogInfo(@"Publishing own nickname: '%@'", rosterName); + if(!rosterName || !rosterName.length) + [self.pubsub deleteNode:@"http://jabber.org/protocol/nick" andHandler:$newHandler(MLPubSubProcessor, rosterNameDeleted)]; + else + [self.pubsub publishItem: + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{@"id": @"current"} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"nick" andNamespace:@"http://jabber.org/protocol/nick" withAttributes:@{} andChildren:@[] andData:rosterName] + ] andData:nil] + onNode:@"http://jabber.org/protocol/nick" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"presence" + } andHandler:$newHandler(MLPubSubProcessor, rosterNamePublished)]; +} + +-(void) publishAvatar:(UIImage*) image +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ + if(!image) + { + DDLogInfo(@"Retracting own avatar image"); + [self.pubsub deleteNode:@"urn:xmpp:avatar:metadata" andHandler:$newHandler(MLPubSubProcessor, avatarDeleted)]; + [self.pubsub deleteNode:@"urn:xmpp:avatar:data" andHandler:$newHandler(MLPubSubProcessor, avatarDeleted)]; + //publish empty metadata node, as per XEP-0084 + [self.pubsub publishItem: + [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"metadata" andNamespace:@"urn:xmpp:avatar:metadata" withAttributes:@{} andChildren:@[] andData:nil] + ] andData:nil] + onNode:@"urn:xmpp:avatar:metadata" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"presence" + } andHandler:$newHandler(MLPubSubProcessor, avatarDeleted)]; + } + else + { + //should work for ejabberd >= 19.02 and prosody >= 0.11 + NSData* imageData = [HelperTools resizeAvatarImage:image withCircularMask:NO toMaxBase64Size:60000]; + NSString* imageHash = [HelperTools hexadecimalString:[HelperTools sha1:imageData]]; + + DDLogInfo(@"Publishing own avatar image with hash %@", imageHash); + + //publish data node (must be done *before* publishing the new metadata node) + MLXMLNode* item = [[MLXMLNode alloc] initWithElement:@"item" withAttributes:@{@"id": imageHash} andChildren:@[ + [[MLXMLNode alloc] initWithElement:@"data" andNamespace:@"urn:xmpp:avatar:data" withAttributes:@{} andChildren:@[] andData:[HelperTools encodeBase64WithData:imageData]] + ] andData:nil]; + + [self.pubsub publishItem:item onNode:@"urn:xmpp:avatar:data" withConfigOptions:@{ + @"pubsub#persist_items": @"true", + @"pubsub#access_model": @"presence" + } andHandler:$newHandler(MLPubSubProcessor, avatarDataPublished, $ID(imageHash), $UINTEGER(imageBytesLen, imageData.length))]; + } + }); +} + +-(void) publishStatusMessage:(NSString*) message +{ + self.statusMessage = message; + [self sendPresence]; +} + +-(NSString*) description +{ + return [NSString stringWithFormat:@"%@[%@]: %@", self.accountID, _internalID, self.connectionProperties.identity.jid]; +} + +@end diff --git a/Monal/Images.xcassets/AlphaAppIcon.appiconset/Contents.json b/Monal/Images.xcassets/AlphaAppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9a1cf9c --- /dev/null +++ b/Monal/Images.xcassets/AlphaAppIcon.appiconset/Contents.json @@ -0,0 +1,32 @@ +{ + "images" : [ + { + "filename" : "Monal-ios_1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "filename" : "Monal-macos-512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "Monal-macos-512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "Monal-macos-1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-ios_1024.png b/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-ios_1024.png new file mode 100644 index 0000000..1592da5 Binary files /dev/null and b/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-ios_1024.png differ diff --git a/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-macos-1024.png b/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-macos-1024.png new file mode 100644 index 0000000..d14dc4c Binary files /dev/null and b/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-macos-1024.png differ diff --git a/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-macos-512.png b/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-macos-512.png new file mode 100644 index 0000000..2653f8e Binary files /dev/null and b/Monal/Images.xcassets/AlphaAppIcon.appiconset/Monal-macos-512.png differ diff --git a/Monal/Images.xcassets/AlphaAppLogo.imageset/Contents.json b/Monal/Images.xcassets/AlphaAppLogo.imageset/Contents.json new file mode 100644 index 0000000..aa2553a --- /dev/null +++ b/Monal/Images.xcassets/AlphaAppLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "rect48.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/AlphaAppLogo.imageset/rect48.png b/Monal/Images.xcassets/AlphaAppLogo.imageset/rect48.png new file mode 100644 index 0000000..1592da5 Binary files /dev/null and b/Monal/Images.xcassets/AlphaAppLogo.imageset/rect48.png differ diff --git a/Monal/Images.xcassets/AppIcon.appiconset/Contents.json b/Monal/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ed5910d --- /dev/null +++ b/Monal/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,67 @@ +{ + "images" : [ + { + "filename" : "Monal-ios_1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "Monal-macos-512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "Monal-macos-512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "Monal-macos-1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/AppIcon.appiconset/Monal-ios_1024.png b/Monal/Images.xcassets/AppIcon.appiconset/Monal-ios_1024.png new file mode 100644 index 0000000..c695a38 Binary files /dev/null and b/Monal/Images.xcassets/AppIcon.appiconset/Monal-ios_1024.png differ diff --git a/Monal/Images.xcassets/AppIcon.appiconset/Monal-macos-1024.png b/Monal/Images.xcassets/AppIcon.appiconset/Monal-macos-1024.png new file mode 100644 index 0000000..d2dd497 Binary files /dev/null and b/Monal/Images.xcassets/AppIcon.appiconset/Monal-macos-1024.png differ diff --git a/Monal/Images.xcassets/AppIcon.appiconset/Monal-macos-512.png b/Monal/Images.xcassets/AppIcon.appiconset/Monal-macos-512.png new file mode 100644 index 0000000..9a8b630 Binary files /dev/null and b/Monal/Images.xcassets/AppIcon.appiconset/Monal-macos-512.png differ diff --git a/Monal/Images.xcassets/AppLogo.imageset/Contents.json b/Monal/Images.xcassets/AppLogo.imageset/Contents.json new file mode 100644 index 0000000..b25d146 --- /dev/null +++ b/Monal/Images.xcassets/AppLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Monal-ios_1024.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/AppLogo.imageset/Monal-ios_1024.png b/Monal/Images.xcassets/AppLogo.imageset/Monal-ios_1024.png new file mode 100644 index 0000000..c695a38 Binary files /dev/null and b/Monal/Images.xcassets/AppLogo.imageset/Monal-ios_1024.png differ diff --git a/Monal/Images.xcassets/CallKitLogo.imageset/Contents.json b/Monal/Images.xcassets/CallKitLogo.imageset/Contents.json new file mode 100644 index 0000000..53ac8b4 --- /dev/null +++ b/Monal/Images.xcassets/CallKitLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "callkit_logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/CallKitLogo.imageset/callkit_logo.png b/Monal/Images.xcassets/CallKitLogo.imageset/callkit_logo.png new file mode 100644 index 0000000..bb4f00d Binary files /dev/null and b/Monal/Images.xcassets/CallKitLogo.imageset/callkit_logo.png differ diff --git a/Monal/Images.xcassets/Contents.json b/Monal/Images.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Contents.json b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Contents.json new file mode 100644 index 0000000..feff128 --- /dev/null +++ b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Contents.json @@ -0,0 +1,32 @@ +{ + "images" : [ + { + "filename" : "Quicksy-ios-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "filename" : "Quicksy-macos-512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "Quicksy-macos-512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "Quicksy-macos-1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-ios-1024.png b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-ios-1024.png new file mode 100644 index 0000000..5d9a67d Binary files /dev/null and b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-ios-1024.png differ diff --git a/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-macos-1024.png b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-macos-1024.png new file mode 100644 index 0000000..7239ddd Binary files /dev/null and b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-macos-1024.png differ diff --git a/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-macos-512.png b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-macos-512.png new file mode 100644 index 0000000..fc0f544 Binary files /dev/null and b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-macos-512.png differ diff --git a/Monal/Images.xcassets/QuicksyAppLogo.imageset/Contents.json b/Monal/Images.xcassets/QuicksyAppLogo.imageset/Contents.json new file mode 100644 index 0000000..9af007e --- /dev/null +++ b/Monal/Images.xcassets/QuicksyAppLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Quicksy-ios-1024.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/QuicksyAppLogo.imageset/Quicksy-ios-1024.png b/Monal/Images.xcassets/QuicksyAppLogo.imageset/Quicksy-ios-1024.png new file mode 100644 index 0000000..5d9a67d Binary files /dev/null and b/Monal/Images.xcassets/QuicksyAppLogo.imageset/Quicksy-ios-1024.png differ diff --git a/Monal/Images.xcassets/QuicksyCallKitLogo.imageset/Contents.json b/Monal/Images.xcassets/QuicksyCallKitLogo.imageset/Contents.json new file mode 100644 index 0000000..53ac8b4 --- /dev/null +++ b/Monal/Images.xcassets/QuicksyCallKitLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "callkit_logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/QuicksyCallKitLogo.imageset/callkit_logo.png b/Monal/Images.xcassets/QuicksyCallKitLogo.imageset/callkit_logo.png new file mode 100644 index 0000000..2251745 Binary files /dev/null and b/Monal/Images.xcassets/QuicksyCallKitLogo.imageset/callkit_logo.png differ diff --git a/Monal/Images.xcassets/colors/Contents.json b/Monal/Images.xcassets/colors/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/Images.xcassets/colors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/colors/activeChatsPinnedColor.colorset/Contents.json b/Monal/Images.xcassets/colors/activeChatsPinnedColor.colorset/Contents.json new file mode 100644 index 0000000..d79ca41 --- /dev/null +++ b/Monal/Images.xcassets/colors/activeChatsPinnedColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "238", + "green" : "229", + "red" : "214" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.933", + "green" : "0.898", + "red" : "0.839" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.404", + "green" : "0.541", + "red" : "0.078" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/colors/chatBackgroundColor.colorset/Contents.json b/Monal/Images.xcassets/colors/chatBackgroundColor.colorset/Contents.json new file mode 100644 index 0000000..a72cd25 --- /dev/null +++ b/Monal/Images.xcassets/colors/chatBackgroundColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x13", + "green" : "0x13", + "red" : "0x13" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/colors/monalGreen.colorset/Contents.json b/Monal/Images.xcassets/colors/monalGreen.colorset/Contents.json new file mode 100644 index 0000000..0ae9dea --- /dev/null +++ b/Monal/Images.xcassets/colors/monalGreen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "103", + "green" : "138", + "red" : "20" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "182", + "green" : "203", + "red" : "128" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/colors/quicksyGreen.colorset/Contents.json b/Monal/Images.xcassets/colors/quicksyGreen.colorset/Contents.json new file mode 100644 index 0000000..dae18d1 --- /dev/null +++ b/Monal/Images.xcassets/colors/quicksyGreen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "79", + "green" : "174", + "red" : "75" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "105", + "green" : "185", + "red" : "102" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/colors/serverDetailsEntryError.colorset/Contents.json b/Monal/Images.xcassets/colors/serverDetailsEntryError.colorset/Contents.json new file mode 100644 index 0000000..07c75fd --- /dev/null +++ b/Monal/Images.xcassets/colors/serverDetailsEntryError.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.780", + "green" : "0.760", + "red" : "0.960" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.470", + "green" : "0.470", + "red" : "0.930" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/colors/serverDetailsEntrySuccess.colorset/Contents.json b/Monal/Images.xcassets/colors/serverDetailsEntrySuccess.colorset/Contents.json new file mode 100644 index 0000000..d3a1411 --- /dev/null +++ b/Monal/Images.xcassets/colors/serverDetailsEntrySuccess.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.960", + "green" : "0.760", + "red" : "0.760" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.930", + "green" : "0.520", + "red" : "0.430" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/colors/serverDetailsEntryWarning.colorset/Contents.json b/Monal/Images.xcassets/colors/serverDetailsEntryWarning.colorset/Contents.json new file mode 100644 index 0000000..8ce05e0 --- /dev/null +++ b/Monal/Images.xcassets/colors/serverDetailsEntryWarning.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.700", + "green" : "0.930", + "red" : "0.960" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.370", + "green" : "0.750", + "red" : "0.820" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/empty/Contents.json b/Monal/Images.xcassets/empty/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/Images.xcassets/empty/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/empty/chats.colorset/Contents.json b/Monal/Images.xcassets/empty/chats.colorset/Contents.json new file mode 100644 index 0000000..db2c52a --- /dev/null +++ b/Monal/Images.xcassets/empty/chats.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.935", + "alpha" : "1.000", + "blue" : "0.910", + "green" : "0.935" + } + } + }, + { + "idiom" : "universal", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.066", + "alpha" : "1.000", + "blue" : "0.088", + "green" : "0.072" + } + } + } + ] +} \ No newline at end of file diff --git a/Monal/Images.xcassets/empty/contacts.colorset/Contents.json b/Monal/Images.xcassets/empty/contacts.colorset/Contents.json new file mode 100644 index 0000000..94c9389 --- /dev/null +++ b/Monal/Images.xcassets/empty/contacts.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.894", + "alpha" : "1.000", + "blue" : "0.804", + "green" : "0.869" + } + } + }, + { + "idiom" : "universal", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.112", + "alpha" : "1.000", + "blue" : "0.208", + "green" : "0.140" + } + } + } + ] +} \ No newline at end of file diff --git a/Monal/Images.xcassets/empty/groups.colorset/Contents.json b/Monal/Images.xcassets/empty/groups.colorset/Contents.json new file mode 100644 index 0000000..bc0d768 --- /dev/null +++ b/Monal/Images.xcassets/empty/groups.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.994", + "alpha" : "1.000", + "blue" : "0.886", + "green" : "0.978" + } + } + }, + { + "idiom" : "universal", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.024", + "alpha" : "1.000", + "blue" : "0.136", + "green" : "0.047" + } + } + } + ] +} \ No newline at end of file diff --git a/Monal/Images.xcassets/incoming-1.imageset/Contents.json b/Monal/Images.xcassets/incoming-1.imageset/Contents.json new file mode 100644 index 0000000..b08bb71 --- /dev/null +++ b/Monal/Images.xcassets/incoming-1.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "bubbleIn.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bubbleIn@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/incoming-1.imageset/bubbleIn.png b/Monal/Images.xcassets/incoming-1.imageset/bubbleIn.png new file mode 100644 index 0000000..6d50da3 Binary files /dev/null and b/Monal/Images.xcassets/incoming-1.imageset/bubbleIn.png differ diff --git a/Monal/Images.xcassets/incoming-1.imageset/bubbleIn@2x.png b/Monal/Images.xcassets/incoming-1.imageset/bubbleIn@2x.png new file mode 100644 index 0000000..422e310 Binary files /dev/null and b/Monal/Images.xcassets/incoming-1.imageset/bubbleIn@2x.png differ diff --git a/Monal/Images.xcassets/incoming.imageset/Contents.json b/Monal/Images.xcassets/incoming.imageset/Contents.json new file mode 100644 index 0000000..b5ec889 --- /dev/null +++ b/Monal/Images.xcassets/incoming.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "bubbleIn.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "bubbleIn@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Images.xcassets/incoming.imageset/bubbleIn.png b/Monal/Images.xcassets/incoming.imageset/bubbleIn.png new file mode 100644 index 0000000..6d50da3 Binary files /dev/null and b/Monal/Images.xcassets/incoming.imageset/bubbleIn.png differ diff --git a/Monal/Images.xcassets/incoming.imageset/bubbleIn@2x.png b/Monal/Images.xcassets/incoming.imageset/bubbleIn@2x.png new file mode 100644 index 0000000..422e310 Binary files /dev/null and b/Monal/Images.xcassets/incoming.imageset/bubbleIn@2x.png differ diff --git a/Monal/Images.xcassets/intro/Contents.json b/Monal/Images.xcassets/intro/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/Images.xcassets/intro/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/outgoing.imageset/Contents.json b/Monal/Images.xcassets/outgoing.imageset/Contents.json new file mode 100644 index 0000000..e3b5911 --- /dev/null +++ b/Monal/Images.xcassets/outgoing.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "bubbleOut.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "bubbleOut@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Images.xcassets/outgoing.imageset/bubbleOut.png b/Monal/Images.xcassets/outgoing.imageset/bubbleOut.png new file mode 100644 index 0000000..32e0fa6 Binary files /dev/null and b/Monal/Images.xcassets/outgoing.imageset/bubbleOut.png differ diff --git a/Monal/Images.xcassets/outgoing.imageset/bubbleOut@2x.png b/Monal/Images.xcassets/outgoing.imageset/bubbleOut@2x.png new file mode 100644 index 0000000..00d88b5 Binary files /dev/null and b/Monal/Images.xcassets/outgoing.imageset/bubbleOut@2x.png differ diff --git a/Monal/Images.xcassets/wallpapers/Contents.json b/Monal/Images.xcassets/wallpapers/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Monal/Images.xcassets/wallpapers/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Images.xcassets/wallpapers/Golden_leaves_by_Mauro_Campanelli.imageset/Contents.json b/Monal/Images.xcassets/wallpapers/Golden_leaves_by_Mauro_Campanelli.imageset/Contents.json new file mode 100644 index 0000000..f42566a --- /dev/null +++ b/Monal/Images.xcassets/wallpapers/Golden_leaves_by_Mauro_Campanelli.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Golden_leaves_by_Mauro_Campanelli.jpg", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Images.xcassets/wallpapers/Golden_leaves_by_Mauro_Campanelli.imageset/Golden_leaves_by_Mauro_Campanelli.jpg b/Monal/Images.xcassets/wallpapers/Golden_leaves_by_Mauro_Campanelli.imageset/Golden_leaves_by_Mauro_Campanelli.jpg new file mode 100644 index 0000000..05e72f1 Binary files /dev/null and b/Monal/Images.xcassets/wallpapers/Golden_leaves_by_Mauro_Campanelli.imageset/Golden_leaves_by_Mauro_Campanelli.jpg differ diff --git a/Monal/Images.xcassets/wallpapers/Stop_the_light_by_Mato_Rachela.imageset/Contents.json b/Monal/Images.xcassets/wallpapers/Stop_the_light_by_Mato_Rachela.imageset/Contents.json new file mode 100644 index 0000000..02b6148 --- /dev/null +++ b/Monal/Images.xcassets/wallpapers/Stop_the_light_by_Mato_Rachela.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Stop_the_light_by_Mato_Rachela.jpg", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Images.xcassets/wallpapers/Stop_the_light_by_Mato_Rachela.imageset/Stop_the_light_by_Mato_Rachela.jpg b/Monal/Images.xcassets/wallpapers/Stop_the_light_by_Mato_Rachela.imageset/Stop_the_light_by_Mato_Rachela.jpg new file mode 100644 index 0000000..d0750cd Binary files /dev/null and b/Monal/Images.xcassets/wallpapers/Stop_the_light_by_Mato_Rachela.imageset/Stop_the_light_by_Mato_Rachela.jpg differ diff --git a/Monal/Images.xcassets/wallpapers/THE_'OUT'_STANDING_by_ydristi.imageset/Contents.json b/Monal/Images.xcassets/wallpapers/THE_'OUT'_STANDING_by_ydristi.imageset/Contents.json new file mode 100644 index 0000000..05e6c99 --- /dev/null +++ b/Monal/Images.xcassets/wallpapers/THE_'OUT'_STANDING_by_ydristi.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "THE_'OUT'_STANDING_by_ydristi.jpg", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Images.xcassets/wallpapers/THE_'OUT'_STANDING_by_ydristi.imageset/THE_'OUT'_STANDING_by_ydristi.jpg b/Monal/Images.xcassets/wallpapers/THE_'OUT'_STANDING_by_ydristi.imageset/THE_'OUT'_STANDING_by_ydristi.jpg new file mode 100644 index 0000000..ff7d649 Binary files /dev/null and b/Monal/Images.xcassets/wallpapers/THE_'OUT'_STANDING_by_ydristi.imageset/THE_'OUT'_STANDING_by_ydristi.jpg differ diff --git a/Monal/Images.xcassets/wallpapers/Tie_My_Boat_by_Ray_Garcia.imageset/Contents.json b/Monal/Images.xcassets/wallpapers/Tie_My_Boat_by_Ray_Garcia.imageset/Contents.json new file mode 100644 index 0000000..d3c0095 --- /dev/null +++ b/Monal/Images.xcassets/wallpapers/Tie_My_Boat_by_Ray_Garcia.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Tie_My_Boat.jpg", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Images.xcassets/wallpapers/Tie_My_Boat_by_Ray_Garcia.imageset/Tie_My_Boat.jpg b/Monal/Images.xcassets/wallpapers/Tie_My_Boat_by_Ray_Garcia.imageset/Tie_My_Boat.jpg new file mode 100644 index 0000000..4287c04 Binary files /dev/null and b/Monal/Images.xcassets/wallpapers/Tie_My_Boat_by_Ray_Garcia.imageset/Tie_My_Boat.jpg differ diff --git a/Monal/Images.xcassets/wallpapers/Winter_Fog_by_Daniel_Vesterskov.imageset/Contents.json b/Monal/Images.xcassets/wallpapers/Winter_Fog_by_Daniel_Vesterskov.imageset/Contents.json new file mode 100644 index 0000000..d035ce4 --- /dev/null +++ b/Monal/Images.xcassets/wallpapers/Winter_Fog_by_Daniel_Vesterskov.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Winter_Fog_by_Daniel_Vesterskov.jpg", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Images.xcassets/wallpapers/Winter_Fog_by_Daniel_Vesterskov.imageset/Winter_Fog_by_Daniel_Vesterskov.jpg b/Monal/Images.xcassets/wallpapers/Winter_Fog_by_Daniel_Vesterskov.imageset/Winter_Fog_by_Daniel_Vesterskov.jpg new file mode 100644 index 0000000..13205f3 Binary files /dev/null and b/Monal/Images.xcassets/wallpapers/Winter_Fog_by_Daniel_Vesterskov.imageset/Winter_Fog_by_Daniel_Vesterskov.jpg differ diff --git a/Monal/Images.xcassets/zwahlen/Contents.json b/Monal/Images.xcassets/zwahlen/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/chat.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/chat.imageset/Contents.json new file mode 100644 index 0000000..e3fa3b5 --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/chat.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "chat.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/chat.imageset/chat.png b/Monal/Images.xcassets/zwahlen/chat.imageset/chat.png new file mode 100644 index 0000000..c1046cc Binary files /dev/null and b/Monal/Images.xcassets/zwahlen/chat.imageset/chat.png differ diff --git a/Monal/Images.xcassets/zwahlen/chat_dark.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/chat_dark.imageset/Contents.json new file mode 100644 index 0000000..d1754c6 --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/chat_dark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "chat_dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/chat_dark.imageset/chat_dark.png b/Monal/Images.xcassets/zwahlen/chat_dark.imageset/chat_dark.png new file mode 100644 index 0000000..2ad8a9e Binary files /dev/null and b/Monal/Images.xcassets/zwahlen/chat_dark.imageset/chat_dark.png differ diff --git a/Monal/Images.xcassets/zwahlen/friends.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/friends.imageset/Contents.json new file mode 100644 index 0000000..25c2ff6 --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/friends.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "friends.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/friends.imageset/friends.png b/Monal/Images.xcassets/zwahlen/friends.imageset/friends.png new file mode 100644 index 0000000..7a7c1e7 Binary files /dev/null and b/Monal/Images.xcassets/zwahlen/friends.imageset/friends.png differ diff --git a/Monal/Images.xcassets/zwahlen/friends_dark.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/friends_dark.imageset/Contents.json new file mode 100644 index 0000000..486b789 --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/friends_dark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "friends_dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/friends_dark.imageset/friends_dark.png b/Monal/Images.xcassets/zwahlen/friends_dark.imageset/friends_dark.png new file mode 100644 index 0000000..969511e Binary files /dev/null and b/Monal/Images.xcassets/zwahlen/friends_dark.imageset/friends_dark.png differ diff --git a/Monal/Images.xcassets/zwahlen/logo.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/logo.imageset/Contents.json new file mode 100644 index 0000000..5f670ca --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/logo.imageset/logo.png b/Monal/Images.xcassets/zwahlen/logo.imageset/logo.png new file mode 100644 index 0000000..a6ebcdb Binary files /dev/null and b/Monal/Images.xcassets/zwahlen/logo.imageset/logo.png differ diff --git a/Monal/Images.xcassets/zwahlen/monal.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/monal.imageset/Contents.json new file mode 100644 index 0000000..1f07ded --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/monal.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "monal.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/monal.imageset/monal.svg b/Monal/Images.xcassets/zwahlen/monal.imageset/monal.svg new file mode 100644 index 0000000..e8c7d1c --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/monal.imageset/monal.svg @@ -0,0 +1,149 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Images.xcassets/zwahlen/park_black_white.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/park_black_white.imageset/Contents.json new file mode 100644 index 0000000..b4ef961 --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/park_black_white.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "park_black_white.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/park_black_white.imageset/park_black_white.png b/Monal/Images.xcassets/zwahlen/park_black_white.imageset/park_black_white.png new file mode 100644 index 0000000..d2d2996 Binary files /dev/null and b/Monal/Images.xcassets/zwahlen/park_black_white.imageset/park_black_white.png differ diff --git a/Monal/Images.xcassets/zwahlen/park_colors.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/park_colors.imageset/Contents.json new file mode 100644 index 0000000..1bbe9bd --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/park_colors.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "park_colors.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/park_colors.imageset/park_colors.png b/Monal/Images.xcassets/zwahlen/park_colors.imageset/park_colors.png new file mode 100644 index 0000000..977e5f8 Binary files /dev/null and b/Monal/Images.xcassets/zwahlen/park_colors.imageset/park_colors.png differ diff --git a/Monal/Images.xcassets/zwahlen/park_white_black.imageset/Contents.json b/Monal/Images.xcassets/zwahlen/park_white_black.imageset/Contents.json new file mode 100644 index 0000000..8610ed7 --- /dev/null +++ b/Monal/Images.xcassets/zwahlen/park_white_black.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "park_white_black.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Images.xcassets/zwahlen/park_white_black.imageset/park_white_black.png b/Monal/Images.xcassets/zwahlen/park_white_black.imageset/park_white_black.png new file mode 100644 index 0000000..0cee88f Binary files /dev/null and b/Monal/Images.xcassets/zwahlen/park_white_black.imageset/park_white_black.png differ diff --git a/Monal/Media.xcassets/Connected.imageset/Connected.png b/Monal/Media.xcassets/Connected.imageset/Connected.png new file mode 100644 index 0000000..211eff2 Binary files /dev/null and b/Monal/Media.xcassets/Connected.imageset/Connected.png differ diff --git a/Monal/Media.xcassets/Connected.imageset/Connected@2x.png b/Monal/Media.xcassets/Connected.imageset/Connected@2x.png new file mode 100644 index 0000000..39ff221 Binary files /dev/null and b/Monal/Media.xcassets/Connected.imageset/Connected@2x.png differ diff --git a/Monal/Media.xcassets/Connected.imageset/Connected@3x.png b/Monal/Media.xcassets/Connected.imageset/Connected@3x.png new file mode 100644 index 0000000..f50b8d1 Binary files /dev/null and b/Monal/Media.xcassets/Connected.imageset/Connected@3x.png differ diff --git a/Monal/Media.xcassets/Connected.imageset/Contents.json b/Monal/Media.xcassets/Connected.imageset/Contents.json new file mode 100644 index 0000000..a1bf0ad --- /dev/null +++ b/Monal/Media.xcassets/Connected.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "Connected.png" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "Connected@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x", + "filename" : "Connected@3x.png" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Media.xcassets/Contents.json b/Monal/Media.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Media.xcassets/NotificationBubble.imageset/Contents.json b/Monal/Media.xcassets/NotificationBubble.imageset/Contents.json new file mode 100644 index 0000000..2d06830 --- /dev/null +++ b/Monal/Media.xcassets/NotificationBubble.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "NotificationBubble.png" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "NotificationBubble@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x", + "filename" : "NotificationBubble@3x.png" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble.png b/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble.png new file mode 100644 index 0000000..2821fb9 Binary files /dev/null and b/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble.png differ diff --git a/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble@2x.png b/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble@2x.png new file mode 100644 index 0000000..6a3eb6c Binary files /dev/null and b/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble@2x.png differ diff --git a/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble@3x.png b/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble@3x.png new file mode 100644 index 0000000..91c7b01 Binary files /dev/null and b/Monal/Media.xcassets/NotificationBubble.imageset/NotificationBubble@3x.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected-1.png b/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected-1.png new file mode 100644 index 0000000..9cdd9a3 Binary files /dev/null and b/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected-1.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected.png b/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected.png new file mode 100644 index 0000000..9cdd9a3 Binary files /dev/null and b/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected@2x-1.png b/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected@2x-1.png new file mode 100644 index 0000000..bbbec50 Binary files /dev/null and b/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected@2x-1.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected@2x.png b/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected@2x.png new file mode 100644 index 0000000..bbbec50 Binary files /dev/null and b/Monal/Media.xcassets/ios7/744-locked-received.imageset/744-locked-selected@2x.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-received.imageset/Contents.json b/Monal/Media.xcassets/ios7/744-locked-received.imageset/Contents.json new file mode 100644 index 0000000..34e213c --- /dev/null +++ b/Monal/Media.xcassets/ios7/744-locked-received.imageset/Contents.json @@ -0,0 +1,54 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "744-locked-selected.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "744-locked-selected-1.png", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "744-locked-selected@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "744-locked-selected@2x-1.png", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "idiom" : "universal", + "scale" : "3x", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ] + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected-1.png b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected-1.png new file mode 100644 index 0000000..e76935e Binary files /dev/null and b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected-1.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected.png b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected.png new file mode 100644 index 0000000..9cdd9a3 Binary files /dev/null and b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected@2x-1.png b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected@2x-1.png new file mode 100644 index 0000000..558de9e Binary files /dev/null and b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected@2x-1.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected@2x.png b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected@2x.png new file mode 100644 index 0000000..bbbec50 Binary files /dev/null and b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/744-locked-selected@2x.png differ diff --git a/Monal/Media.xcassets/ios7/744-locked-selected.imageset/Contents.json b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/Contents.json new file mode 100644 index 0000000..34e213c --- /dev/null +++ b/Monal/Media.xcassets/ios7/744-locked-selected.imageset/Contents.json @@ -0,0 +1,54 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "744-locked-selected.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "744-locked-selected-1.png", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "744-locked-selected@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "744-locked-selected@2x-1.png", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "idiom" : "universal", + "scale" : "3x", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ] + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked-1.png b/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked-1.png new file mode 100644 index 0000000..f80ed1e Binary files /dev/null and b/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked-1.png differ diff --git a/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked.png b/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked.png new file mode 100644 index 0000000..f8848d6 Binary files /dev/null and b/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked.png differ diff --git a/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked@2x-1.png b/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked@2x-1.png new file mode 100644 index 0000000..f781325 Binary files /dev/null and b/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked@2x-1.png differ diff --git a/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked@2x.png b/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked@2x.png new file mode 100644 index 0000000..007b955 Binary files /dev/null and b/Monal/Media.xcassets/ios7/745-unlocked.imageset/745-unlocked@2x.png differ diff --git a/Monal/Media.xcassets/ios7/745-unlocked.imageset/Contents.json b/Monal/Media.xcassets/ios7/745-unlocked.imageset/Contents.json new file mode 100644 index 0000000..2299e9d --- /dev/null +++ b/Monal/Media.xcassets/ios7/745-unlocked.imageset/Contents.json @@ -0,0 +1,54 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "745-unlocked.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "745-unlocked-1.png", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "745-unlocked@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "745-unlocked@2x-1.png", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "idiom" : "universal", + "scale" : "3x", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ] + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Media.xcassets/ios7/Contents.json b/Monal/Media.xcassets/ios7/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/Media.xcassets/ios7/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Media.xcassets/ios7/New Folder/Contents.json b/Monal/Media.xcassets/ios7/New Folder/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/Media.xcassets/ios7/New Folder/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Media.xcassets/noicon.imageset/Contents.json b/Monal/Media.xcassets/noicon.imageset/Contents.json new file mode 100644 index 0000000..ab78e54 --- /dev/null +++ b/Monal/Media.xcassets/noicon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "noicon.png" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "noicon@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Monal/Media.xcassets/noicon.imageset/noicon.png b/Monal/Media.xcassets/noicon.imageset/noicon.png new file mode 100644 index 0000000..df1b731 Binary files /dev/null and b/Monal/Media.xcassets/noicon.imageset/noicon.png differ diff --git a/Monal/Media.xcassets/noicon.imageset/noicon@2x.png b/Monal/Media.xcassets/noicon.imageset/noicon@2x.png new file mode 100644 index 0000000..27162af Binary files /dev/null and b/Monal/Media.xcassets/noicon.imageset/noicon@2x.png differ diff --git a/Monal/Media.xcassets/noicon_channel.imageset/Contents.json b/Monal/Media.xcassets/noicon_channel.imageset/Contents.json new file mode 100644 index 0000000..f9e5c72 --- /dev/null +++ b/Monal/Media.xcassets/noicon_channel.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "noicon_channel.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "noicon_channelx2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "noicon_channelx3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Media.xcassets/noicon_channel.imageset/noicon_channel.png b/Monal/Media.xcassets/noicon_channel.imageset/noicon_channel.png new file mode 100644 index 0000000..b0ac3d7 Binary files /dev/null and b/Monal/Media.xcassets/noicon_channel.imageset/noicon_channel.png differ diff --git a/Monal/Media.xcassets/noicon_channel.imageset/noicon_channelx2.png b/Monal/Media.xcassets/noicon_channel.imageset/noicon_channelx2.png new file mode 100644 index 0000000..a4ba454 Binary files /dev/null and b/Monal/Media.xcassets/noicon_channel.imageset/noicon_channelx2.png differ diff --git a/Monal/Media.xcassets/noicon_channel.imageset/noicon_channelx3.png b/Monal/Media.xcassets/noicon_channel.imageset/noicon_channelx3.png new file mode 100644 index 0000000..a8632ce Binary files /dev/null and b/Monal/Media.xcassets/noicon_channel.imageset/noicon_channelx3.png differ diff --git a/Monal/Media.xcassets/noicon_muc.imageset/Contents.json b/Monal/Media.xcassets/noicon_muc.imageset/Contents.json new file mode 100644 index 0000000..d502664 --- /dev/null +++ b/Monal/Media.xcassets/noicon_muc.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "noicon_muc.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "noicon_muc2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/Media.xcassets/noicon_muc.imageset/noicon_muc.png b/Monal/Media.xcassets/noicon_muc.imageset/noicon_muc.png new file mode 100644 index 0000000..e579318 Binary files /dev/null and b/Monal/Media.xcassets/noicon_muc.imageset/noicon_muc.png differ diff --git a/Monal/Media.xcassets/noicon_muc.imageset/noicon_muc2x.png b/Monal/Media.xcassets/noicon_muc.imageset/noicon_muc2x.png new file mode 100644 index 0000000..5cef924 Binary files /dev/null and b/Monal/Media.xcassets/noicon_muc.imageset/noicon_muc2x.png differ diff --git a/Monal/Monal-Info.plist b/Monal/Monal-Info.plist new file mode 100644 index 0000000..b426877 --- /dev/null +++ b/Monal/Monal-Info.plist @@ -0,0 +1,152 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + im.monal.process + im.monal.refresh + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${PRODUCT_NAME} + CFBundleDocumentTypes + + + CFBundleTypeName + XMPP Message + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + im.monal.xmpp + + + + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + xmpp + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + monalOpen + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.social-networking + LSApplicationQueriesSchemes + + dbapi-2 + + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + Monal allows users to take photos and upload to a conversation + NSLocationUsageDescription + Monal uses your location when you send a location message in a conversation. + NSLocationWhenInUseUsageDescription + Monal uses your location when you send a location message in a conversation. + NSMicrophoneUsageDescription + Monal uses the microphone to transmit your voice in audio messages or calls. + NSPhotoLibraryAddUsageDescription + Monal allows users to save photos received in conversations. + NSPhotoLibraryUsageDescription + Monal allows users to upload photos to recipients in a conversation + NSUserActivityTypes + + INSendMessageIntent + INStartCallIntent + + SBUsesNetwork + + SRResearchDataGeneration + + UIBackgroundModes + + audio + fetch + processing + remote-notification + voip + + UIFileSharingEnabled + + UILaunchStoryboardName + Launch Screen + UIMainStoryboardFile + Main + UIPrerenderedIcon + + UIRequiresFullScreen + + UIRequiresPersistentWiFi + + UIStatusBarStyle + UIStatusBarStyleLightContent + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.xml + + UTTypeDescription + XMPP Message + UTTypeIdentifier + im.monal.xmpp + UTTypeTagSpecification + + public.filename-extension + + xmpp + + + + + + diff --git a/Monal/Monal-iOS/Launch Screen.storyboard b/Monal/Monal-iOS/Launch Screen.storyboard new file mode 100644 index 0000000..26ec0ec --- /dev/null +++ b/Monal/Monal-iOS/Launch Screen.storyboard @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal-iOS/Quicksy Launch Screen.storyboard b/Monal/Monal-iOS/Quicksy Launch Screen.storyboard new file mode 100644 index 0000000..3172981 --- /dev/null +++ b/Monal/Monal-iOS/Quicksy Launch Screen.storyboard @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.Alpha-Info.plist b/Monal/Monal.Alpha-Info.plist new file mode 100644 index 0000000..783dc56 --- /dev/null +++ b/Monal/Monal.Alpha-Info.plist @@ -0,0 +1,152 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + im.monal.alpha.process + im.monal.alpha.refresh + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${PRODUCT_NAME} + CFBundleDocumentTypes + + + CFBundleTypeName + XMPP Message + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + im.monal.xmpp + + + + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + xmpp + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + monalAlphaOpen + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.social-networking + LSApplicationQueriesSchemes + + dbapi-2 + + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + Monal allows users to take photos and upload to a conversation + NSLocationUsageDescription + Monal uses your location when you send a location message in a conversation. + NSLocationWhenInUseUsageDescription + Monal uses your location when you send a location message in a conversation. + NSMicrophoneUsageDescription + Monal uses the microphone to transmit your voice in audio calls. + NSPhotoLibraryAddUsageDescription + Monal allows users to save photos received in conversations. + NSPhotoLibraryUsageDescription + Monal allows users to upload photos to recipients in a conversation + NSUserActivityTypes + + INSendMessageIntent + INStartCallIntent + + SBUsesNetwork + + UIBackgroundModes + + audio + fetch + processing + remote-notification + voip + + UIFileSharingEnabled + + UILaunchStoryboardName + Launch Screen + UIMainStoryboardFile + Main + UIPrerenderedIcon + + UIRequiresFullScreen + + UIRequiresPersistentWiFi + + UIStatusBarStyle + UIStatusBarStyleLightContent + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.xml + + UTTypeDescription + XMPP Message + UTTypeIdentifier + im.monal.xmpp + UTTypeTagSpecification + + public.filename-extension + + xmpp + + + + + SRResearchDataGeneration + + + diff --git a/Monal/Monal.Alpha.ios.entitlements b/Monal/Monal.Alpha.ios.entitlements new file mode 100644 index 0000000..d550327 --- /dev/null +++ b/Monal/Monal.Alpha.ios.entitlements @@ -0,0 +1,34 @@ + + + + + aps-environment + production + com.apple.developer.kernel.increased-memory-limit + + com.apple.developer.usernotifications.communication + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monalalpha + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.personal-information.location + + com.apple.security.personal-information.photos-library + + keychain-access-groups + + $(AppIdentifierPrefix)monal.alpha + + + diff --git a/Monal/Monal.Alpha.macos.entitlements b/Monal/Monal.Alpha.macos.entitlements new file mode 100644 index 0000000..9134ea6 --- /dev/null +++ b/Monal/Monal.Alpha.macos.entitlements @@ -0,0 +1,34 @@ + + + + + aps-environment + production + com.apple.developer.usernotifications.filtering + + com.apple.developer.usernotifications.communication + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monalalpha + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.personal-information.location + + keychain-access-groups + + $(AppIdentifierPrefix)monal.alpha + + + diff --git a/Monal/Monal.ios.entitlements b/Monal/Monal.ios.entitlements new file mode 100644 index 0000000..485d26e --- /dev/null +++ b/Monal/Monal.ios.entitlements @@ -0,0 +1,38 @@ + + + + + aps-environment + production + com.apple.developer.usernotifications.filtering + + com.apple.developer.kernel.increased-memory-limit + + com.apple.developer.usernotifications.communication + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monal + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.personal-information.location + + com.apple.security.personal-information.photos-library + + keychain-access-groups + + $(AppIdentifierPrefix)G7YU7X7KRJ.SworIM + + + diff --git a/Monal/Monal.macos.entitlements b/Monal/Monal.macos.entitlements new file mode 100644 index 0000000..d1f1b4a --- /dev/null +++ b/Monal/Monal.macos.entitlements @@ -0,0 +1,32 @@ + + + + + aps-environment + production + com.apple.developer.usernotifications.filtering + + com.apple.developer.usernotifications.communication + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monal + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.personal-information.location + + keychain-access-groups + + $(AppIdentifierPrefix)org.monal-im.prod.catalyst.monal + + + diff --git a/Monal/Monal.xcodeproj/.LSOverride b/Monal/Monal.xcodeproj/.LSOverride new file mode 100644 index 0000000..28aa1bf Binary files /dev/null and b/Monal/Monal.xcodeproj/.LSOverride differ diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3cb83ee --- /dev/null +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -0,0 +1,5415 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 08CAF17FA202CF3CB760D93C /* Pods_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2B7A5555D807EE78C95217FD /* Pods_NotificationService.framework */; }; + 1D3623260D0F684500981E51 /* MonalAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 1D3623250D0F684500981E51 /* MonalAppDelegate.m */; }; + 1D60589B0D05DD56006BFB54 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B97316FDCFA39411CA2CEA /* main.m */; }; + 20D3611C2C10E12500E46587 /* BoardingCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D3611B2C10E12500E46587 /* BoardingCards.swift */; }; + 20D8C65E2C3C37FE00E6BDA2 /* MediaGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D8C65D2C3C37FE00E6BDA2 /* MediaGallery.swift */; }; + 20ED55852BADDA5C0005783E /* GeneralSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20ED55842BADDA5C0005783E /* GeneralSettings.swift */; }; + 2601D9CB0FBF25EF004DB939 /* sworim.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 2601D9CA0FBF25EF004DB939 /* sworim.sqlite */; }; + 260773C4232FC4E800BFD50F /* NotificationService.m in Sources */ = {isa = PBXBuildFile; fileRef = 260773C3232FC4E800BFD50F /* NotificationService.m */; }; + 2609B5291FD5B26800F09FA1 /* MLSplitViewDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 2609B5281FD5B26800F09FA1 /* MLSplitViewDelegate.m */; }; + 26158AF21FFA6E4500E53BDC /* MLWebViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 26158AF11FFA6E4500E53BDC /* MLWebViewController.m */; }; + 26158AF41FFA8A6A00E53BDC /* opensource.html in Resources */ = {isa = PBXBuildFile; fileRef = 26158AF31FFA8A6A00E53BDC /* opensource.html */; }; + 261A628B176C159000059090 /* AccountListController.m in Sources */ = {isa = PBXBuildFile; fileRef = 261A628A176C159000059090 /* AccountListController.m */; }; + 261E542523A0A1D300394F59 /* monalxmpp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26CC579223A0867400ABB92A /* monalxmpp.framework */; }; + 261E542623A0A1D300394F59 /* monalxmpp.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 26CC579223A0867400ABB92A /* monalxmpp.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 262797AF178A577300B85D94 /* MLContactCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 262797AE178A577300B85D94 /* MLContactCell.m */; }; + 262AEFE820AE756800498F82 /* MLMAMPrefTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 262AEFE720AE756800498F82 /* MLMAMPrefTableViewController.m */; }; + 262E51921AD8CAC600788351 /* MLButtonCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 262E51901AD8CAC600788351 /* MLButtonCell.m */; }; + 262E51931AD8CAC600788351 /* MLButtonCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 262E51911AD8CAC600788351 /* MLButtonCell.xib */; }; + 262E51971AD8CB7200788351 /* MLTextInputCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 262E51951AD8CB7200788351 /* MLTextInputCell.m */; }; + 262E51981AD8CB7200788351 /* MLTextInputCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 262E51961AD8CB7200788351 /* MLTextInputCell.xib */; }; + 2636C43F177BD58C001CA71F /* XMPPEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 2636C43E177BD58C001CA71F /* XMPPEdit.m */; }; + 2638008B2374816D00144929 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 260773C0232FC4E800BFD50F /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 263DFAC02183D7160038E716 /* MLSelectionController.m in Sources */ = {isa = PBXBuildFile; fileRef = 263DFABE2183A59B0038E716 /* MLSelectionController.m */; }; + 263DFAC32187D0E00038E716 /* MLLinkCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 263DFAC22187D0E00038E716 /* MLLinkCell.m */; }; + 2644D4921FF0064C00F46AB5 /* MLChatImageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 2644D4911FF0064C00F46AB5 /* MLChatImageCell.m */; }; + 2644D4951FF046E800F46AB5 /* MLBaseCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 2644D4941FF046E800F46AB5 /* MLBaseCell.m */; }; + 2644D4991FF29E5600F46AB5 /* MLSettingsTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2644D4981FF29E5600F46AB5 /* MLSettingsTableViewController.m */; }; + 26470F521835C4080069E3E0 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26470F511835C4080069E3E0 /* Media.xcassets */; }; + 2649CE531C51B84C00CD1E80 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2649CE521C51B84C00CD1E80 /* Launch Screen.storyboard */; }; + 264A7E751AA7263600E860E3 /* MLSwitchCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 264A7E741AA7263600E860E3 /* MLSwitchCell.xib */; }; + 2664D28523F2312400CD4085 /* MLAccountPickerViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2664D28423F2312400CD4085 /* MLAccountPickerViewController.m */; }; + 268DD58617C4541000C673A9 /* MLChatCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 268DD58517C4541000C673A9 /* MLChatCell.m */; }; + 2696EED21791245A00BC54B8 /* chatViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2696EED11791245A00BC54B8 /* chatViewController.m */; }; + 26AA70152146BBB900598605 /* ShareViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 26AA70142146BBB900598605 /* ShareViewController.m */; }; + 26AA70182146BBB900598605 /* iosShare.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 26AA70162146BBB900598605 /* iosShare.storyboard */; }; + 26AA701C2146BBB900598605 /* shareSheet.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 26AA70112146BBB800598605 /* shareSheet.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 26AAAADC2295742500200433 /* MLPasswordChangeTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 26AAAAD92295742400200433 /* MLPasswordChangeTableViewController.m */; }; + 26AAE285179F7B0200271345 /* MLSettingCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 26AAE284179F7B0200271345 /* MLSettingCell.m */; }; + 26B0CA8921AE2E3C0080B133 /* MLSoundsTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 26B0CA8821AE2E3C0080B133 /* MLSoundsTableViewController.m */; }; + 26B0CA8B21AE410E0080B133 /* AlertSounds in Resources */ = {isa = PBXBuildFile; fileRef = 26B0CA8A21AE410E0080B133 /* AlertSounds */; }; + 26B2A4BB1B73061400272E63 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26B2A4BA1B73061400272E63 /* Images.xcassets */; }; + 26CC579623A0867400ABB92A /* monalxmpp.h in Headers */ = {isa = PBXBuildFile; fileRef = 26CC579423A0867400ABB92A /* monalxmpp.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 26CC57A223A086AA00ABB92A /* xmpp.m in Sources */ = {isa = PBXBuildFile; fileRef = 260C51D0177F08F50039634B /* xmpp.m */; }; + 26CC57A323A086AA00ABB92A /* MLDNSLookup.m in Sources */ = {isa = PBXBuildFile; fileRef = 26C4B22321B6B91300610282 /* MLDNSLookup.m */; }; + 26CC57A423A086AA00ABB92A /* MLXMPPServer.m in Sources */ = {isa = PBXBuildFile; fileRef = 2661100C238F08820030A4EE /* MLXMPPServer.m */; }; + 26CC57A523A086AA00ABB92A /* MLXMPPIdentity.m in Sources */ = {isa = PBXBuildFile; fileRef = 26611011238F08980030A4EE /* MLXMPPIdentity.m */; }; + 26CC57A623A086AA00ABB92A /* MLXMPPConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = 26611016238F08AC0030A4EE /* MLXMPPConnection.m */; }; + 26CC57B223A086CC00ABB92A /* MLXMLNode.m in Sources */ = {isa = PBXBuildFile; fileRef = 260C51D3177F44AD0039634B /* MLXMLNode.m */; }; + 26CC57B323A086CC00ABB92A /* XMPPIQ.m in Sources */ = {isa = PBXBuildFile; fileRef = 26CF3EA91780D6B7002B7085 /* XMPPIQ.m */; }; + 26CC57B423A086CC00ABB92A /* XMPPPresence.m in Sources */ = {isa = PBXBuildFile; fileRef = 264E345A1787BAB100BC7BD0 /* XMPPPresence.m */; }; + 26CC57B523A086CC00ABB92A /* XMPPMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = 262D9EA917921E82009292B4 /* XMPPMessage.m */; }; + 26CC57B623A0876300ABB92A /* AESGcm.m in Sources */ = {isa = PBXBuildFile; fileRef = 267B671B226A200D003DB850 /* AESGcm.m */; }; + 26CC57B723A0876300ABB92A /* MLEncryptedPayload.m in Sources */ = {isa = PBXBuildFile; fileRef = 267B6720226A222E003DB850 /* MLEncryptedPayload.m */; }; + 26CC57B823A0876300ABB92A /* MLHTTPRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = 26C3456D1C68F9F0007F4BEC /* MLHTTPRequest.m */; }; + 26CC57C723A0892100ABB92A /* MLContact.m in Sources */ = {isa = PBXBuildFile; fileRef = 2661102A238F17CB0030A4EE /* MLContact.m */; }; + 26CC57C823A0892100ABB92A /* MLMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = 26611025238F17B20030A4EE /* MLMessage.m */; }; + 26CC57C923A0892800ABB92A /* MLMessageProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = 2633CB42231CB816006D0277 /* MLMessageProcessor.m */; }; + 26CC57CA23A0892800ABB92A /* MLIQProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = 2661101B238F10360030A4EE /* MLIQProcessor.m */; }; + 26CC57CB23A0892800ABB92A /* MLPresenceProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = 26611020238F104B0030A4EE /* MLPresenceProcessor.m */; }; + 26CC57D323A08D7100ABB92A /* DataLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 2601D9A70FBF255D004DB939 /* DataLayer.m */; }; + 26CC57D423A08D7100ABB92A /* MLImageManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 26EC411217BDE39E0031304D /* MLImageManager.m */; }; + 26CC57D523A08D7100ABB92A /* MLSignalStore.m in Sources */ = {isa = PBXBuildFile; fileRef = 263AFDFB209B3B35007F9CEE /* MLSignalStore.m */; }; + 26CC57DA23A08DD800ABB92A /* MLXMPPManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 26352EA7177D222600E2C8FF /* MLXMPPManager.m */; }; + 26CC57EB23A08F4400ABB92A /* monalxmpp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26CC579223A0867400ABB92A /* monalxmpp.framework */; }; + 26D4389123A5EB6C00242AAA /* MLConstants.h in Headers */ = {isa = PBXBuildFile; fileRef = 262D9EAB17924532009292B4 /* MLConstants.h */; }; + 26D7C05E23D6AFD800CA123C /* MLChatInputContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = 26D7C05D23D6AFD800CA123C /* MLChatInputContainer.m */; }; + 26D89F061A890672009B147C /* MLSwitchCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 26D89F051A890672009B147C /* MLSwitchCell.m */; }; + 26E8462224EABAD600ECE419 /* Settings.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 26E8462424EABAD600ECE419 /* Settings.storyboard */; }; + 26E8462824EABAED00ECE419 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 26E8462A24EABAED00ECE419 /* Main.storyboard */; }; + 26F9794D1ACAC73A0008E005 /* MLContactCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 26F9794C1ACAC73A0008E005 /* MLContactCell.xib */; }; + 26FE3BCB1C61A6C3003CC230 /* MLResizingTextView.m in Sources */ = {isa = PBXBuildFile; fileRef = 26FE3BCA1C61A6C3003CC230 /* MLResizingTextView.m */; }; + 34BC08122C5E9BE30099FB85 /* ContentUnavailableShimView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BC08112C5E9BE30099FB85 /* ContentUnavailableShimView.swift */; }; + 34E58B4B2C68E7BC009A1634 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34E58B4A2C68E7BC009A1634 /* ContactsView.swift */; }; + 38720923251EDE07001837EB /* MLXEPSlashMeHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 38720921251EDE07001837EB /* MLXEPSlashMeHandler.m */; }; + 389E298C25E901CA009A5268 /* MLAudioRecoderManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 389E298925E901CA009A5268 /* MLAudioRecoderManager.m */; }; + 389E298D25E901CA009A5268 /* MLAudioRecoderManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 389E298B25E901CA009A5268 /* MLAudioRecoderManager.h */; }; + 3D06A515281FFCC000DDAE90 /* NotificationDebugging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D06A514281FFCC000DDAE90 /* NotificationDebugging.swift */; }; + 3D27D956290B0BB60014748B /* AddContactMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D27D955290B0BB60014748B /* AddContactMenu.swift */; }; + 3D27D958290B0BC80014748B /* ContactRequestsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D27D957290B0BC80014748B /* ContactRequestsMenu.swift */; }; + 3D5A91422842B4AE008CE57E /* MemberList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5A91412842B4AE008CE57E /* MemberList.swift */; }; + 3D65B78D27234B74005A30F4 /* ContactDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D65B78C27234B74005A30F4 /* ContactDetails.swift */; }; + 3D65B791272350F0005A30F4 /* SwiftuiHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D65B790272350F0005A30F4 /* SwiftuiHelpers.swift */; }; + 3D7D352328626CB80042C5E5 /* LoadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7D352228626CB80042C5E5 /* LoadingOverlay.swift */; }; + 3D85E587282AE523006F5B3A /* OmemoQrCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D85E586282AE523006F5B3A /* OmemoQrCodeView.swift */; }; + 3DC5035C2822F5220064C8A7 /* OmemoKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC5035B2822F5220064C8A7 /* OmemoKeysView.swift */; }; + 43A92B80C8CD5E04074B5A3E /* Pods_MonalXMPPUnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C59BAF969550DFAC27E5F2B /* Pods_MonalXMPPUnitTests.framework */; }; + 540A3FAD24D674BD0008965D /* MLSQLite.m in Sources */ = {isa = PBXBuildFile; fileRef = 540A3FAC24D674BD0008965D /* MLSQLite.m */; }; + 540BD0D224D8D1F40087A743 /* IPC.h in Headers */ = {isa = PBXBuildFile; fileRef = 540BD0D124D8D1F20087A743 /* IPC.h */; }; + 540BD0D424D8D2000087A743 /* IPC.m in Sources */ = {isa = PBXBuildFile; fileRef = 540BD0D324D8D1FF0087A743 /* IPC.m */; }; + 540E13A024CDCDB30038FDA0 /* MLProcessLock.m in Sources */ = {isa = PBXBuildFile; fileRef = 540E139F24CDCDB30038FDA0 /* MLProcessLock.m */; }; + 540E13A224CDCE3B0038FDA0 /* MLProcessLock.h in Headers */ = {isa = PBXBuildFile; fileRef = 540E13A124CDCE3B0038FDA0 /* MLProcessLock.h */; }; + 540E13A524CF6A8C0038FDA0 /* MLNotificationManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 26EE8B1E179B67FA006781F3 /* MLNotificationManager.m */; }; + 540F625F24BA951E0008A6D8 /* HelperTools.m in Sources */ = {isa = PBXBuildFile; fileRef = C1AAC3E324B5EF4100BB15D6 /* HelperTools.m */; }; + 54179CC0251CBAFA008F398E /* XMPPStanza.m in Sources */ = {isa = PBXBuildFile; fileRef = 54179CBF251CBAF9008F398E /* XMPPStanza.m */; }; + 54179CC3251CBB2B008F398E /* XMPPStanza.h in Headers */ = {isa = PBXBuildFile; fileRef = 54179CC2251CBB2B008F398E /* XMPPStanza.h */; }; + 541B6AB4262BC9040038B936 /* MLStream.m in Sources */ = {isa = PBXBuildFile; fileRef = 541B6AB2262BC9040038B936 /* MLStream.m */; }; + 541B6AB5262BC9040038B936 /* MLStream.h in Headers */ = {isa = PBXBuildFile; fileRef = 541B6AB3262BC9040038B936 /* MLStream.h */; }; + 541E4CBC254AA08700FD7B28 /* MLSQLite.h in Headers */ = {isa = PBXBuildFile; fileRef = 541E4CBB254AA08600FD7B28 /* MLSQLite.h */; }; + 541E4CBE254AA0B600FD7B28 /* MLHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = 541E4CBD254AA0B600FD7B28 /* MLHandler.h */; }; + 541E4CC0254AA0E700FD7B28 /* MLHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 541E4CBF254AA0E700FD7B28 /* MLHandler.m */; }; + 541E4CC4254D369200FD7B28 /* MLPubSubProcessor.h in Headers */ = {isa = PBXBuildFile; fileRef = 541E4CC3254D369100FD7B28 /* MLPubSubProcessor.h */; }; + 541E4CC7254D370200FD7B28 /* MLPubSubProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = 541E4CC6254D370100FD7B28 /* MLPubSubProcessor.m */; }; + 5427C94C276A6BC4003217D5 /* UIColor+Extension.h in Headers */ = {isa = PBXBuildFile; fileRef = 54391BB9273499E80050DAFB /* UIColor+Extension.h */; }; + 5427C94E276A6BE1003217D5 /* UIColor+Extension.m in Sources */ = {isa = PBXBuildFile; fileRef = 54391BBA273499E80050DAFB /* UIColor+Extension.m */; }; + 542CF3FF2763314F002C3710 /* hsluv.c in Sources */ = {isa = PBXBuildFile; fileRef = 542CF3FD2763314E002C3710 /* hsluv.c */; }; + 542CF4002763314F002C3710 /* hsluv.h in Headers */ = {isa = PBXBuildFile; fileRef = 542CF3FE2763314F002C3710 /* hsluv.h */; }; + 54391BBB273499E80050DAFB /* UIColor+Extension.m in Sources */ = {isa = PBXBuildFile; fileRef = 54391BBA273499E80050DAFB /* UIColor+Extension.m */; }; + 544656BB2534910D006B2953 /* XMPPDataForm.m in Sources */ = {isa = PBXBuildFile; fileRef = 544656BA2534910D006B2953 /* XMPPDataForm.m */; }; + 544656BD25349133006B2953 /* XMPPDataForm.h in Headers */ = {isa = PBXBuildFile; fileRef = 544656BC25349133006B2953 /* XMPPDataForm.h */; }; + 54507CE5255D8C14007092F4 /* MLFiletransfer.m in Sources */ = {isa = PBXBuildFile; fileRef = 54507CE4255D8C14007092F4 /* MLFiletransfer.m */; }; + 54507CE8255D8C47007092F4 /* MLFiletransfer.h in Headers */ = {isa = PBXBuildFile; fileRef = 54507CE7255D8C47007092F4 /* MLFiletransfer.h */; }; + 5455BC3324EB776F0024D80F /* MLUDPLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 5455BC3024EB776B0024D80F /* MLUDPLogger.m */; }; + 5455BC3424EB776F0024D80F /* MLUDPLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = 5455BC3224EB776E0024D80F /* MLUDPLogger.h */; }; + 54A22D2526185C2900B56EAD /* MLNotificationQueue.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A22D2426185C2900B56EAD /* MLNotificationQueue.m */; }; + 54A22D2D26185E7E00B56EAD /* MLNotificationQueue.h in Headers */ = {isa = PBXBuildFile; fileRef = 54A22D2C26185E7E00B56EAD /* MLNotificationQueue.h */; }; + 54D2307E24CB0F4600638D65 /* monalxmpp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26CC579223A0867400ABB92A /* monalxmpp.framework */; }; + 54D2308424CB10EE00638D65 /* MLLogFileManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C15D504A24C727CC002F75BB /* MLLogFileManager.m */; }; + 54E594BD2523C34B00E4172B /* MLPubSub.h in Headers */ = {isa = PBXBuildFile; fileRef = 54E594BA2523C34900E4172B /* MLPubSub.h */; }; + 54E594BE2523C34B00E4172B /* MLPubSub.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E594BC2523C34A00E4172B /* MLPubSub.m */; }; + 54F0B81928231691003664BD /* WelcomeLogIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F0B81828231690003664BD /* WelcomeLogIn.swift */; }; + 54F0B81C282316F5003664BD /* RegisterAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F0B81B282316F5003664BD /* RegisterAccount.swift */; }; + 6E9488F6997650B805476F25 /* Pods_another_im.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F29121F912380F72CCE51747 /* Pods_another_im.framework */; }; + 7D40218FEAB3BA882811A682 /* Pods_Monal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8C40963CED187B2F1B4B88F7 /* Pods_Monal.framework */; }; + 7E995F242CEAC5D2005B30EE /* AnotherIMApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E995F202CEAC5D2005B30EE /* AnotherIMApp.swift */; }; + 7E995F252CEAC5D2005B30EE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E995F222CEAC5D2005B30EE /* ContentView.swift */; }; + 7E995F272CEAC5D2005B30EE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7E995F212CEAC5D2005B30EE /* Assets.xcassets */; }; + 7E995F2B2CEAC9A0005B30EE /* monalxmpp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26CC579223A0867400ABB92A /* monalxmpp.framework */; }; + 7E995F2C2CEAC9A0005B30EE /* monalxmpp.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 26CC579223A0867400ABB92A /* monalxmpp.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 7E995F302CEAC9F6005B30EE /* Pods_monalxmpp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B9C86E0A568734587FE9BA2 /* Pods_monalxmpp.framework */; }; + 840E23CA28ADA56900A7FAC9 /* MLUploadQueueCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 840E23C828ADA56900A7FAC9 /* MLUploadQueueCell.m */; }; + 8414AE002A7ABC4300EFFCCC /* LibMonalRustSwiftBridge in Frameworks */ = {isa = PBXBuildFile; productRef = 8414ADFF2A7ABC4300EFFCCC /* LibMonalRustSwiftBridge */; }; + 841898AA2957712000FEC77D /* ViewExtractor in Frameworks */ = {isa = PBXBuildFile; productRef = 841898A92957712000FEC77D /* ViewExtractor */; }; + 841898AC2957DBAD00FEC77D /* RichAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841898AB2957DBAC00FEC77D /* RichAlert.swift */; }; + 8418B55D2C7EC6B7006FAF60 /* Quicksy_Country.h in Headers */ = {isa = PBXBuildFile; fileRef = 8418B55B2C7EC6B7006FAF60 /* Quicksy_Country.h */; }; + 8418B55E2C7EC6B7006FAF60 /* Quicksy_Country.m in Sources */ = {isa = PBXBuildFile; fileRef = 8418B55C2C7EC6B7006FAF60 /* Quicksy_Country.m */; }; + 8418B5632C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.h in Headers */ = {isa = PBXBuildFile; fileRef = 8418B5612C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.h */; }; + 8418B5642C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.m in Sources */ = {isa = PBXBuildFile; fileRef = 8418B5622C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.m */; }; + 8418B5672C87E0ED006FAF60 /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = 8418B5662C87E0ED006FAF60 /* ExyteChat */; }; + 841B6F1A297B18720074F9B7 /* AccountPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841B6F19297B18720074F9B7 /* AccountPicker.swift */; }; + 841B6F1C297B3CFC0074F9B7 /* AVCallUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841B6F1B297B3CFC0074F9B7 /* AVCallUI.swift */; }; + 841EE4302A426F2300D3AF14 /* MLCrashReporter.m in Sources */ = {isa = PBXBuildFile; fileRef = 841EE42F2A426F2300D3AF14 /* MLCrashReporter.m */; }; + 8420EA9B2915E4FE0038FF40 /* OmemoState.h in Headers */ = {isa = PBXBuildFile; fileRef = 8420EA9A2915E4FE0038FF40 /* OmemoState.h */; }; + 8420EA9D2915E5100038FF40 /* OmemoState.m in Sources */ = {isa = PBXBuildFile; fileRef = 8420EA9C2915E5100038FF40 /* OmemoState.m */; }; + 842790852A32D16D005C18CC /* CallSounds in Resources */ = {isa = PBXBuildFile; fileRef = 842790842A32D16C005C18CC /* CallSounds */; }; + 843AD3AB2AA55CE20036844D /* MLOgHtmlParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843AD3AA2AA55CE20036844D /* MLOgHtmlParser.swift */; }; + 8441EFF92921B53500E851E9 /* BackgroundSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8441EFF82921B53500E851E9 /* BackgroundSettings.swift */; }; + 844921EA2C29F9A000B99A9C /* MLDelayableTimer.m in Sources */ = {isa = PBXBuildFile; fileRef = 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */; }; + 844921EC2C29F9BE00B99A9C /* MLDelayableTimer.h in Headers */ = {isa = PBXBuildFile; fileRef = 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */; }; + 845836BA2C49F36300B11EC5 /* Quicksy Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 845836B92C49F36300B11EC5 /* Quicksy Launch Screen.storyboard */; }; + 845D636B2AD4AEDA0066EFFB /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845D636A2AD4AEDA0066EFFB /* MediaViewer.swift */; }; + 845EFFBD2918721800C1E03E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; + 845EFFBE2918723D00C1E03E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; + 846DF27C2937FAA600AAB9C0 /* ChatPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */; }; + 848227912C4A6194003CCA33 /* MLPlaceholderViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */; }; + 848717F3295ED64600B8D288 /* MLCall.m in Sources */ = {isa = PBXBuildFile; fileRef = 848717F1295ED64500B8D288 /* MLCall.m */; }; + 848904A9289C82C30097E19C /* SCRAM.m in Sources */ = {isa = PBXBuildFile; fileRef = 848904A8289C82C30097E19C /* SCRAM.m */; }; + 848C73E02BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 848C73DF2BDF2014007035C9 /* PrivacyInfo.xcprivacy */; }; + 848C73E12BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 848C73DF2BDF2014007035C9 /* PrivacyInfo.xcprivacy */; }; + 848C73E22BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 848C73DF2BDF2014007035C9 /* PrivacyInfo.xcprivacy */; }; + 849248492AD4CEC400986C1A /* ZoomableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849248482AD4CEC400986C1A /* ZoomableContainer.swift */; }; + 849A53E4287135B2007E941A /* MLVoIPProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = 849A53E3287135B2007E941A /* MLVoIPProcessor.m */; }; + 849ADF3F2BACF0360009BCD7 /* CocoaLumberjack in Frameworks */ = {isa = PBXBuildFile; productRef = 849ADF3E2BACF0360009BCD7 /* CocoaLumberjack */; }; + 849ADF412BACF0360009BCD7 /* CocoaLumberjackSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 849ADF402BACF0360009BCD7 /* CocoaLumberjackSwift */; }; + 849ADF432BACF0360009BCD7 /* CocoaLumberjackSwiftLogBackend in Frameworks */ = {isa = PBXBuildFile; productRef = 849ADF422BACF0360009BCD7 /* CocoaLumberjackSwiftLogBackend */; }; + 84BBAECA2C42D272009492E2 /* Quicksy_RegisterAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BBAEC92C42D272009492E2 /* Quicksy_RegisterAccount.swift */; }; + 84C1CD502A8C764D007076ED /* SwiftHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C1CD4F2A8C764D007076ED /* SwiftHelpers.swift */; }; + 84C1CD522A8F617F007076ED /* MLStreamRedirect.m in Sources */ = {isa = PBXBuildFile; fileRef = 84C1CD512A8F617F007076ED /* MLStreamRedirect.m */; }; + 84C1CD542A8F6196007076ED /* MLStreamRedirect.h in Headers */ = {isa = PBXBuildFile; fileRef = 84C1CD532A8F6196007076ED /* MLStreamRedirect.h */; }; + 84D31CE628653B83006D7926 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D31CE528653B83006D7926 /* WebRTCClient.swift */; }; + 84E231F32C16A9CE00735FB7 /* SVGView in Frameworks */ = {isa = PBXBuildFile; productRef = 84E231F22C16A9CE00735FB7 /* SVGView */; }; + 84E55E7D2964424E003E191A /* ActiveChatsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 261A6284176C156500059090 /* ActiveChatsViewController.m */; }; + 84F194D12C15197200F0A994 /* FrameUp in Frameworks */ = {isa = PBXBuildFile; productRef = 84F194D02C15197200F0A994 /* FrameUp */; }; + 84FC37552897521500634E3E /* snprintf.m in Sources */ = {isa = PBXBuildFile; fileRef = 84FC37542897521400634E3E /* snprintf.m */; }; + 84FC37572897523500634E3E /* metamacros.h in Headers */ = {isa = PBXBuildFile; fileRef = 84FC37562897523500634E3E /* metamacros.h */; }; + 84FC375928981A5600634E3E /* PasswordMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FC375828981A5600634E3E /* PasswordMigration.swift */; }; + 952EBC802BAF72F300183DBF /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 952EBC7F2BAF72F300183DBF /* DebugView.swift */; }; + C10490492612ED2F0054AC9E /* MLEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10490482612ED2F0054AC9E /* MLEmoji.swift */; }; + C10490E32612F3D00054AC9E /* MLCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10490E22612F3D00054AC9E /* MLCrypto.swift */; }; + C10490EB2612F3E00054AC9E /* EncryptedPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10490EA2612F3E00054AC9E /* EncryptedPayload.swift */; }; + C1049189261301530054AC9E /* MonalXMPPUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1049188261301530054AC9E /* MonalXMPPUnitTests.swift */; }; + C104918B261301530054AC9E /* monalxmpp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26CC579223A0867400ABB92A /* monalxmpp.framework */; }; + C1049199261301710054AC9E /* MLCryptoTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1049198261301710054AC9E /* MLCryptoTest.swift */; }; + C114D13D2B15B903000FB99F /* ContactEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C114D13C2B15B903000FB99F /* ContactEntry.swift */; }; + C117F7E12B086390001F2BC6 /* CreateGroupMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D88BB76295BB6DC00FB30BA /* CreateGroupMenu.swift */; }; + C117F7E22B0863B3001F2BC6 /* ContactPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D631822294BAB1D00026BE7 /* ContactPicker.swift */; }; + C12436142434AB5D00B8F074 /* MLAttributedLabel.m in Sources */ = {isa = PBXBuildFile; fileRef = C12436132434AB5D00B8F074 /* MLAttributedLabel.m */; }; + C1414E9D24312F0100948788 /* MLChatMapsCell.m in Sources */ = {isa = PBXBuildFile; fileRef = C1414E9C24312F0100948788 /* MLChatMapsCell.m */; }; + C15489B925680BBE00BBA2F0 /* MLQRCodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15489B825680BBE00BBA2F0 /* MLQRCodeScanner.swift */; }; + C158D40025A0AB810005AA40 /* MLMucProcessor.h in Headers */ = {isa = PBXBuildFile; fileRef = C158D3FE25A0AB810005AA40 /* MLMucProcessor.h */; }; + C158D41425A0AC630005AA40 /* MLMucProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = C158D41225A0AC630005AA40 /* MLMucProcessor.m */; }; + C1613B5A2520723D0062C0C2 /* MLBasePaser.h in Headers */ = {isa = PBXBuildFile; fileRef = C1613B572520723C0062C0C2 /* MLBasePaser.h */; }; + C1613B5B2520723D0062C0C2 /* MLBasePaser.m in Sources */ = {isa = PBXBuildFile; fileRef = C1613B592520723C0062C0C2 /* MLBasePaser.m */; }; + C16D18352792A4AF00F869A0 /* DataLayerMigrations.h in Headers */ = {isa = PBXBuildFile; fileRef = C16D18332792A4AF00F869A0 /* DataLayerMigrations.h */; }; + C16D18362792A4AF00F869A0 /* DataLayerMigrations.m in Sources */ = {isa = PBXBuildFile; fileRef = C16D18342792A4AF00F869A0 /* DataLayerMigrations.m */; }; + C176F1EC2AF11C31002034E5 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C176F1EB2AF11C31002034E5 /* UserNotifications.framework */; }; + C1850EB825F38A2D003D506A /* MonalUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1850EB725F38A2D003D506A /* MonalUITests.swift */; }; + C1850EC625F3C5EB003D506A /* TestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1850EC525F3C5EB003D506A /* TestHelper.swift */; }; + C18967C72B81F61B0073C7C5 /* ChannelMemberList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18967C62B81F61B0073C7C5 /* ChannelMemberList.swift */; }; + C18E757A245E8AE900AE8FB7 /* MLPipe.h in Headers */ = {isa = PBXBuildFile; fileRef = C18E7578245E8AE900AE8FB7 /* MLPipe.h */; }; + C18E757C245E8AE900AE8FB7 /* MLPipe.m in Sources */ = {isa = PBXBuildFile; fileRef = C18E7579245E8AE900AE8FB7 /* MLPipe.m */; }; + C1943A4C25309A9D0036172F /* MLReloadCell.m in Sources */ = {isa = PBXBuildFile; fileRef = C1943A4B25309A9D0036172F /* MLReloadCell.m */; }; + C1A80DA424D9552400B99E01 /* MLChatViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = C1A80DA324D9552400B99E01 /* MLChatViewHelper.m */; }; + C1B940EA26144AF500E9D290 /* AESGCMTest.m in Sources */ = {isa = PBXBuildFile; fileRef = C1B940E926144AF500E9D290 /* AESGCMTest.m */; }; + C1C839DD24F15DF800BBCF17 /* MLOMEMO.h in Headers */ = {isa = PBXBuildFile; fileRef = C1C839DA24F15DF800BBCF17 /* MLOMEMO.h */; }; + C1C839DE24F15DF800BBCF17 /* MLOMEMO.m in Sources */ = {isa = PBXBuildFile; fileRef = C1C839DC24F15DF800BBCF17 /* MLOMEMO.m */; }; + C1D7D7AF283FB4E500401389 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26B2A4BA1B73061400272E63 /* Images.xcassets */; }; + C1D7D7B0283FB4E700401389 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26470F511835C4080069E3E0 /* Media.xcassets */; }; + C1E4654824EE517000CA5AAF /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; + C1E8A7F72B8E47C300760220 /* EditGroupSubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E8A7F62B8E47C300760220 /* EditGroupSubject.swift */; }; + C1F5C7A92775DA000001F295 /* MLContactSoftwareVersionInfo.h in Headers */ = {isa = PBXBuildFile; fileRef = C1F5C7A72775DA000001F295 /* MLContactSoftwareVersionInfo.h */; }; + C1F5C7AA2775DA000001F295 /* MLContactSoftwareVersionInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F5C7A82775DA000001F295 /* MLContactSoftwareVersionInfo.m */; }; + C1F5C7AC2777621B0001F295 /* ContactResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F5C7AB2777621B0001F295 /* ContactResources.swift */; }; + C1F5C7AF2777638B0001F295 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = C1F5C7AE2777638B0001F295 /* OrderedCollections */; }; + D02192F32C89BB3800202A59 /* BlockedUsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02192F22C89BB3800202A59 /* BlockedUsers.swift */; }; + D09B51F62C7F30DD008D725B /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26B2A4BA1B73061400272E63 /* Images.xcassets */; }; + D0FA79B12C7E5C7400216D2A /* ServerDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA79B02C7E5C7400216D2A /* ServerDetails.swift */; }; + D7E74AF213445E39318BC648 /* Pods_MonalUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29250DA62DD2322383585B2B /* Pods_MonalUITests.framework */; }; + E89DD32525C6626400925F62 /* MLFileTransferDataCell.m in Sources */ = {isa = PBXBuildFile; fileRef = E89DD32025C6626300925F62 /* MLFileTransferDataCell.m */; }; + E89DD32625C6626400925F62 /* MLFileTransferVideoCell.m in Sources */ = {isa = PBXBuildFile; fileRef = E89DD32125C6626300925F62 /* MLFileTransferVideoCell.m */; }; + E89DD32725C6626400925F62 /* MLFileTransferFileViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E89DD32225C6626300925F62 /* MLFileTransferFileViewController.m */; }; + E89DD32825C6626400925F62 /* MLFileTransferTextCell.m in Sources */ = {isa = PBXBuildFile; fileRef = E89DD32425C6626400925F62 /* MLFileTransferTextCell.m */; }; + E8CF9CC726249640001A1952 /* MLSettingsAboutViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E8CF9CC026249640001A1952 /* MLSettingsAboutViewController.m */; }; + E8DED06225388BE8003167FF /* MLSearchViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E8DED06125388BE8003167FF /* MLSearchViewController.m */; }; + F9C277F46F5157194744C491 /* Pods_shareSheet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F1488206B764014DD7EC92A /* Pods_shareSheet.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 263800892374814000144929 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 260773BF232FC4E800BFD50F; + remoteInfo = NotificaionService; + }; + 26AA701A2146BBB900598605 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26AA70102146BBB800598605; + remoteInfo = shareSheet; + }; + 26CC579723A0867400ABB92A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26CC579123A0867400ABB92A; + remoteInfo = monalxmpp; + }; + 26CC57E723A08F3A00ABB92A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26CC579123A0867400ABB92A; + remoteInfo = monalxmpp; + }; + 26CC57E923A08F3F00ABB92A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26CC579123A0867400ABB92A; + remoteInfo = monalxmpp; + }; + 7E995F2D2CEAC9A0005B30EE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26CC579123A0867400ABB92A; + remoteInfo = monalxmpp; + }; + C104918C261301530054AC9E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26CC579123A0867400ABB92A; + remoteInfo = monalxmpp; + }; + C1850EBA25F38A2D003D506A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1D6058900D05DD3D006BFB54; + remoteInfo = Monal; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 261E542723A0A1D300394F59 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 261E542623A0A1D300394F59 /* monalxmpp.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 26AA70212146BBB900598605 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 2638008B2374816D00144929 /* NotificationService.appex in Embed Foundation Extensions */, + 26AA701C2146BBB900598605 /* shareSheet.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + 7E995F2F2CEAC9A0005B30EE /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 7E995F2C2CEAC9A0005B30EE /* monalxmpp.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 061EF1BEDEE7A71FDF9AB402 /* Pods-shareSheet.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.appstore.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.appstore.xcconfig"; sourceTree = ""; }; + 0D0362F6B26B9407BE313F36 /* Pods-NotificaionService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificaionService.debug.xcconfig"; path = "Target Support Files/Pods-NotificaionService/Pods-NotificaionService.debug.xcconfig"; sourceTree = ""; }; + 0EC0A5305E72C0F7F9E1CDEF /* Pods-shareSheet.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.adhoc.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.adhoc.xcconfig"; sourceTree = ""; }; + 1059D059522057BFA830995B /* Pods-shareSheet.alpha-catalyst.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.alpha-catalyst.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.alpha-catalyst.xcconfig"; sourceTree = ""; }; + 140EC3B65541FD42B8A9A3C4 /* Pods-MonalXMPPUnitTests.alpha-ios.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalXMPPUnitTests.alpha-ios.xcconfig"; path = "Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests.alpha-ios.xcconfig"; sourceTree = ""; }; + 1936866375CABF471D3CE238 /* Pods-another.im.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-another.im.debug.xcconfig"; path = "Target Support Files/Pods-another.im/Pods-another.im.debug.xcconfig"; sourceTree = ""; }; + 1D3623240D0F684500981E51 /* MonalAppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MonalAppDelegate.h; path = Classes/MonalAppDelegate.h; sourceTree = ""; }; + 1D3623250D0F684500981E51 /* MonalAppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MonalAppDelegate.m; path = Classes/MonalAppDelegate.m; sourceTree = ""; }; + 1D46F251C198E3D8FA55692F /* Pods-Monal.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.appstore.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.appstore.xcconfig"; sourceTree = ""; }; + 20D3611B2C10E12500E46587 /* BoardingCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoardingCards.swift; sourceTree = ""; }; + 20D8C65D2C3C37FE00E6BDA2 /* MediaGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGallery.swift; sourceTree = ""; }; + 20ED55842BADDA5C0005783E /* GeneralSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettings.swift; sourceTree = ""; }; + 213F5BFD4599EC9317B99E97 /* Pods-Monal.appstore-quicksy.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.appstore-quicksy.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.appstore-quicksy.xcconfig"; sourceTree = ""; }; + 21E99538324C14220843F325 /* Pods-shareSheet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.debug.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.debug.xcconfig"; sourceTree = ""; }; + 222F09C97CFF93A2CF1007F3 /* Pods-MonalUITests.alpha-ios.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalUITests.alpha-ios.xcconfig"; path = "Target Support Files/Pods-MonalUITests/Pods-MonalUITests.alpha-ios.xcconfig"; sourceTree = ""; }; + 2369191B3FCB2E941169A093 /* Pods-MonalXMPPUnitTests.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalXMPPUnitTests.appstore.xcconfig"; path = "Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests.appstore.xcconfig"; sourceTree = ""; }; + 2601D9A70FBF255D004DB939 /* DataLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataLayer.m; sourceTree = ""; }; + 2601D9A80FBF255D004DB939 /* DataLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DataLayer.h; sourceTree = ""; }; + 2601D9CA0FBF25EF004DB939 /* sworim.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = sworim.sqlite; sourceTree = ""; }; + 260773C0232FC4E800BFD50F /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 260773C2232FC4E800BFD50F /* NotificationService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NotificationService.h; sourceTree = ""; }; + 260773C3232FC4E800BFD50F /* NotificationService.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NotificationService.m; sourceTree = ""; }; + 260773C5232FC4E800BFD50F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 26080210110ABA4E005E194D /* Monal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Monal.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2609B5271FD5B26800F09FA1 /* MLSplitViewDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLSplitViewDelegate.h; sourceTree = ""; }; + 2609B5281FD5B26800F09FA1 /* MLSplitViewDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLSplitViewDelegate.m; sourceTree = ""; }; + 260C51CF177F08F50039634B /* xmpp.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = xmpp.h; sourceTree = ""; }; + 260C51D0177F08F50039634B /* xmpp.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = xmpp.m; sourceTree = ""; }; + 260C51D2177F44AD0039634B /* MLXMLNode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLXMLNode.h; sourceTree = ""; }; + 260C51D3177F44AD0039634B /* MLXMLNode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLXMLNode.m; sourceTree = ""; }; + 26158AF01FFA6E4500E53BDC /* MLWebViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLWebViewController.h; sourceTree = ""; }; + 26158AF11FFA6E4500E53BDC /* MLWebViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLWebViewController.m; sourceTree = ""; }; + 26158AF31FFA8A6A00E53BDC /* opensource.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = opensource.html; sourceTree = ""; }; + 261A6284176C156500059090 /* ActiveChatsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ActiveChatsViewController.m; sourceTree = ""; }; + 261A6289176C159000059090 /* AccountListController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AccountListController.h; sourceTree = ""; }; + 261A628A176C159000059090 /* AccountListController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AccountListController.m; sourceTree = ""; }; + 262797AD178A577300B85D94 /* MLContactCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLContactCell.h; sourceTree = ""; }; + 262797AE178A577300B85D94 /* MLContactCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLContactCell.m; sourceTree = ""; }; + 262AEFE620AE756800498F82 /* MLMAMPrefTableViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLMAMPrefTableViewController.h; sourceTree = ""; }; + 262AEFE720AE756800498F82 /* MLMAMPrefTableViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLMAMPrefTableViewController.m; sourceTree = ""; }; + 262D9EA817921E82009292B4 /* XMPPMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = XMPPMessage.h; sourceTree = ""; }; + 262D9EA917921E82009292B4 /* XMPPMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = XMPPMessage.m; sourceTree = ""; }; + 262D9EAB17924532009292B4 /* MLConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLConstants.h; sourceTree = ""; }; + 262E518F1AD8CAC600788351 /* MLButtonCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLButtonCell.h; sourceTree = ""; }; + 262E51901AD8CAC600788351 /* MLButtonCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLButtonCell.m; sourceTree = ""; }; + 262E51911AD8CAC600788351 /* MLButtonCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MLButtonCell.xib; sourceTree = ""; }; + 262E51941AD8CB7200788351 /* MLTextInputCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLTextInputCell.h; sourceTree = ""; }; + 262E51951AD8CB7200788351 /* MLTextInputCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLTextInputCell.m; sourceTree = ""; }; + 262E51961AD8CB7200788351 /* MLTextInputCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MLTextInputCell.xib; sourceTree = ""; }; + 2633CB41231CB816006D0277 /* MLMessageProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLMessageProcessor.h; sourceTree = ""; }; + 2633CB42231CB816006D0277 /* MLMessageProcessor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLMessageProcessor.m; sourceTree = ""; }; + 26352EA6177D222600E2C8FF /* MLXMPPManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLXMPPManager.h; sourceTree = ""; }; + 26352EA7177D222600E2C8FF /* MLXMPPManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLXMPPManager.m; sourceTree = ""; }; + 2636C43D177BD58C001CA71F /* XMPPEdit.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = XMPPEdit.h; sourceTree = ""; }; + 2636C43E177BD58C001CA71F /* XMPPEdit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = XMPPEdit.m; sourceTree = ""; }; + 263AFDFA209B3B35007F9CEE /* MLSignalStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLSignalStore.h; sourceTree = ""; }; + 263AFDFB209B3B35007F9CEE /* MLSignalStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLSignalStore.m; sourceTree = ""; }; + 263DFABD2183A59B0038E716 /* MLSelectionController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLSelectionController.h; sourceTree = ""; }; + 263DFABE2183A59B0038E716 /* MLSelectionController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLSelectionController.m; sourceTree = ""; }; + 263DFAC12187D0E00038E716 /* MLLinkCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLLinkCell.h; sourceTree = ""; }; + 263DFAC22187D0E00038E716 /* MLLinkCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLLinkCell.m; sourceTree = ""; }; + 2644D4901FF0064C00F46AB5 /* MLChatImageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLChatImageCell.h; sourceTree = ""; }; + 2644D4911FF0064C00F46AB5 /* MLChatImageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLChatImageCell.m; sourceTree = ""; }; + 2644D4931FF046E800F46AB5 /* MLBaseCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLBaseCell.h; sourceTree = ""; }; + 2644D4941FF046E800F46AB5 /* MLBaseCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLBaseCell.m; sourceTree = ""; }; + 2644D4971FF29E5600F46AB5 /* MLSettingsTableViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLSettingsTableViewController.h; sourceTree = ""; }; + 2644D4981FF29E5600F46AB5 /* MLSettingsTableViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLSettingsTableViewController.m; sourceTree = ""; }; + 26470F511835C4080069E3E0 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; + 2649CE521C51B84C00CD1E80 /* Launch Screen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = "Launch Screen.storyboard"; path = "Monal-iOS/Launch Screen.storyboard"; sourceTree = ""; }; + 264A7E741AA7263600E860E3 /* MLSwitchCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MLSwitchCell.xib; sourceTree = ""; }; + 264E34591787BAB100BC7BD0 /* XMPPPresence.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = XMPPPresence.h; sourceTree = ""; }; + 264E345A1787BAB100BC7BD0 /* XMPPPresence.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = XMPPPresence.m; sourceTree = ""; }; + 2661100B238F08820030A4EE /* MLXMPPServer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLXMPPServer.h; sourceTree = ""; }; + 2661100C238F08820030A4EE /* MLXMPPServer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLXMPPServer.m; sourceTree = ""; }; + 26611010238F08980030A4EE /* MLXMPPIdentity.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLXMPPIdentity.h; sourceTree = ""; }; + 26611011238F08980030A4EE /* MLXMPPIdentity.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLXMPPIdentity.m; sourceTree = ""; }; + 26611015238F08AC0030A4EE /* MLXMPPConnection.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLXMPPConnection.h; sourceTree = ""; }; + 26611016238F08AC0030A4EE /* MLXMPPConnection.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLXMPPConnection.m; sourceTree = ""; }; + 2661101A238F10360030A4EE /* MLIQProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLIQProcessor.h; sourceTree = ""; }; + 2661101B238F10360030A4EE /* MLIQProcessor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLIQProcessor.m; sourceTree = ""; }; + 2661101F238F104B0030A4EE /* MLPresenceProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLPresenceProcessor.h; sourceTree = ""; }; + 26611020238F104B0030A4EE /* MLPresenceProcessor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLPresenceProcessor.m; sourceTree = ""; }; + 26611024238F17B20030A4EE /* MLMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLMessage.h; sourceTree = ""; }; + 26611025238F17B20030A4EE /* MLMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLMessage.m; sourceTree = ""; }; + 26611029238F17CA0030A4EE /* MLContact.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLContact.h; sourceTree = ""; }; + 2661102A238F17CB0030A4EE /* MLContact.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLContact.m; sourceTree = ""; }; + 2664D28323F2312400CD4085 /* MLAccountPickerViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLAccountPickerViewController.h; sourceTree = ""; }; + 2664D28423F2312400CD4085 /* MLAccountPickerViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLAccountPickerViewController.m; sourceTree = ""; }; + 266A0B7924EAC35B00875DF8 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = external/uk.lproj/iosShare.strings; sourceTree = ""; }; + 266A0B7A24EAC35F00875DF8 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = external/uk.lproj/Main.strings; sourceTree = ""; }; + 266A0B7C24EAC36600875DF8 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = external/uk.lproj/Settings.strings; sourceTree = ""; }; + 267B671A226A200D003DB850 /* AESGcm.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AESGcm.h; sourceTree = ""; }; + 267B671B226A200D003DB850 /* AESGcm.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AESGcm.m; sourceTree = ""; }; + 267B671F226A222E003DB850 /* MLEncryptedPayload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLEncryptedPayload.h; sourceTree = ""; }; + 267B6720226A222E003DB850 /* MLEncryptedPayload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLEncryptedPayload.m; sourceTree = ""; }; + 268DD58417C4541000C673A9 /* MLChatCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLChatCell.h; sourceTree = ""; }; + 268DD58517C4541000C673A9 /* MLChatCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLChatCell.m; sourceTree = ""; }; + 2696EED01791245A00BC54B8 /* chatViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = chatViewController.h; sourceTree = ""; }; + 2696EED11791245A00BC54B8 /* chatViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = chatViewController.m; sourceTree = ""; }; + 26AA70112146BBB800598605 /* shareSheet.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = shareSheet.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 26AA70132146BBB900598605 /* ShareViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShareViewController.h; sourceTree = ""; }; + 26AA70142146BBB900598605 /* ShareViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ShareViewController.m; sourceTree = ""; }; + 26AA70172146BBB900598605 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/iosShare.storyboard; sourceTree = ""; }; + 26AA70192146BBB900598605 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 26AA70222146E2B900598605 /* shareSheet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = shareSheet.entitlements; sourceTree = ""; }; + 26AAAAD82295742400200433 /* MLPasswordChangeTableViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLPasswordChangeTableViewController.h; sourceTree = ""; }; + 26AAAAD92295742400200433 /* MLPasswordChangeTableViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLPasswordChangeTableViewController.m; sourceTree = ""; }; + 26AAE283179F7B0200271345 /* MLSettingCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLSettingCell.h; sourceTree = ""; }; + 26AAE284179F7B0200271345 /* MLSettingCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLSettingCell.m; sourceTree = ""; }; + 26ABE9FB494A9E7F3044C695 /* Pods-MonalUITests.appstore-quicksy.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalUITests.appstore-quicksy.xcconfig"; path = "Target Support Files/Pods-MonalUITests/Pods-MonalUITests.appstore-quicksy.xcconfig"; sourceTree = ""; }; + 26B0CA8721AE2E3C0080B133 /* MLSoundsTableViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLSoundsTableViewController.h; sourceTree = ""; }; + 26B0CA8821AE2E3C0080B133 /* MLSoundsTableViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLSoundsTableViewController.m; sourceTree = ""; }; + 26B0CA8A21AE410E0080B133 /* AlertSounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AlertSounds; sourceTree = ""; }; + 26B2A4BA1B73061400272E63 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; + 26C3456C1C68F9F0007F4BEC /* MLHTTPRequest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLHTTPRequest.h; sourceTree = ""; }; + 26C3456D1C68F9F0007F4BEC /* MLHTTPRequest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLHTTPRequest.m; sourceTree = ""; }; + 26C4B22221B6B91300610282 /* MLDNSLookup.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLDNSLookup.h; sourceTree = ""; }; + 26C4B22321B6B91300610282 /* MLDNSLookup.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLDNSLookup.m; sourceTree = ""; }; + 26CC579223A0867400ABB92A /* monalxmpp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = monalxmpp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 26CC579423A0867400ABB92A /* monalxmpp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = monalxmpp.h; sourceTree = ""; }; + 26CC579523A0867400ABB92A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 26CF3EA81780D6B7002B7085 /* XMPPIQ.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = XMPPIQ.h; sourceTree = ""; }; + 26CF3EA91780D6B7002B7085 /* XMPPIQ.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = XMPPIQ.m; sourceTree = ""; }; + 26D7C05C23D6AFD800CA123C /* MLChatInputContainer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLChatInputContainer.h; sourceTree = ""; }; + 26D7C05D23D6AFD800CA123C /* MLChatInputContainer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLChatInputContainer.m; sourceTree = ""; }; + 26D89F041A890672009B147C /* MLSwitchCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLSwitchCell.h; sourceTree = ""; }; + 26D89F051A890672009B147C /* MLSwitchCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLSwitchCell.m; sourceTree = ""; }; + 26E8462324EABAD600ECE419 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Settings.storyboard; sourceTree = ""; }; + 26E8462924EABAED00ECE419 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 26E8462B24EABBFC00ECE419 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = external/de.lproj/Main.strings; sourceTree = ""; }; + 26E8462D24EABC0300ECE419 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = external/de.lproj/Settings.strings; sourceTree = ""; }; + 26E8462F24EABC0800ECE419 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = external/de.lproj/iosShare.strings; sourceTree = ""; }; + 26E8463024EABCDE00ECE419 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "external/es-419.lproj/Main.strings"; sourceTree = ""; }; + 26E8463224EABCEA00ECE419 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "external/es-419.lproj/Settings.strings"; sourceTree = ""; }; + 26E8463424EABCF100ECE419 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "external/es-419.lproj/iosShare.strings"; sourceTree = ""; }; + 26E8463524EABD4100ECE419 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = external/fr.lproj/Main.strings; sourceTree = ""; }; + 26E8463724EABD5300ECE419 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = external/fr.lproj/Settings.strings; sourceTree = ""; }; + 26E8463924EABD6200ECE419 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = external/fr.lproj/iosShare.strings; sourceTree = ""; }; + 26E8463A24EABFD900ECE419 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = external/es.lproj/Main.strings; sourceTree = ""; }; + 26E8463C24EABFE000ECE419 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = external/es.lproj/Settings.strings; sourceTree = ""; }; + 26E8463E24EABFE300ECE419 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = external/es.lproj/iosShare.strings; sourceTree = ""; }; + 26E8463F24EAC04400ECE419 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = external/ja.lproj/Main.strings; sourceTree = ""; }; + 26E8464124EAC05400ECE419 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = external/ja.lproj/Settings.strings; sourceTree = ""; }; + 26E8464324EAC05700ECE419 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = external/ja.lproj/iosShare.strings; sourceTree = ""; }; + 26E8464424EAC05800ECE419 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = external/it.lproj/Main.strings; sourceTree = ""; }; + 26E8464624EAC05B00ECE419 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = external/it.lproj/Settings.strings; sourceTree = ""; }; + 26E8464824EAC05E00ECE419 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = external/it.lproj/iosShare.strings; sourceTree = ""; }; + 26E8464924EAC08800ECE419 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = external/nl.lproj/Main.strings; sourceTree = ""; }; + 26E8464B24EAC09D00ECE419 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = external/nl.lproj/Settings.strings; sourceTree = ""; }; + 26E8464D24EAC0AC00ECE419 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = external/pl.lproj/Main.strings; sourceTree = ""; }; + 26E8464F24EAC0AE00ECE419 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = external/pl.lproj/Settings.strings; sourceTree = ""; }; + 26E8465124EAC0B100ECE419 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = external/nl.lproj/iosShare.strings; sourceTree = ""; }; + 26E8465224EAC0B400ECE419 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = external/pl.lproj/iosShare.strings; sourceTree = ""; }; + 26E8465824EAC0E300ECE419 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = external/he.lproj/Main.strings; sourceTree = ""; }; + 26E8465A24EAC0EA00ECE419 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = external/he.lproj/Settings.strings; sourceTree = ""; }; + 26E8465C24EAC0EE00ECE419 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = external/he.lproj/iosShare.strings; sourceTree = ""; }; + 26E8465D24EAC0F900ECE419 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = external/ru.lproj/Main.strings; sourceTree = ""; }; + 26E8465F24EAC0FF00ECE419 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = external/ru.lproj/Settings.strings; sourceTree = ""; }; + 26E8466124EAC10300ECE419 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = external/ru.lproj/iosShare.strings; sourceTree = ""; }; + 26E8466224EAC11100ECE419 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = external/hi.lproj/Main.strings; sourceTree = ""; }; + 26E8466424EAC11800ECE419 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = external/hi.lproj/Settings.strings; sourceTree = ""; }; + 26E8466624EAC11C00ECE419 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = external/hi.lproj/iosShare.strings; sourceTree = ""; }; + 26E8466724EAC13200ECE419 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "external/pt-PT.lproj/Main.strings"; sourceTree = ""; }; + 26E8466924EAC13800ECE419 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "external/pt-PT.lproj/Settings.strings"; sourceTree = ""; }; + 26E8466B24EAC13E00ECE419 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "external/pt-PT.lproj/iosShare.strings"; sourceTree = ""; }; + 26E8466C24EAC14A00ECE419 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "external/pt-BR.lproj/Main.strings"; sourceTree = ""; }; + 26E8466E24EAC14D00ECE419 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "external/pt-BR.lproj/Settings.strings"; sourceTree = ""; }; + 26E8467024EAC15000ECE419 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "external/pt-BR.lproj/iosShare.strings"; sourceTree = ""; }; + 26E8467124EAC16200ECE419 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = external/ro.lproj/Main.strings; sourceTree = ""; }; + 26E8467324EAC16900ECE419 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = external/ro.lproj/Settings.strings; sourceTree = ""; }; + 26E8467524EAC16D00ECE419 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = external/ro.lproj/iosShare.strings; sourceTree = ""; }; + 26E8467624EAC19C00ECE419 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = external/vi.lproj/Main.strings; sourceTree = ""; }; + 26E8467824EAC1A200ECE419 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = external/vi.lproj/Settings.strings; sourceTree = ""; }; + 26E8467E24EAC1BB00ECE419 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = external/vi.lproj/iosShare.strings; sourceTree = ""; }; + 26EC411117BDE39E0031304D /* MLImageManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLImageManager.h; sourceTree = ""; }; + 26EC411217BDE39E0031304D /* MLImageManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLImageManager.m; sourceTree = ""; }; + 26EE8B1D179B67FA006781F3 /* MLNotificationManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MLNotificationManager.h; path = Classes/MLNotificationManager.h; sourceTree = ""; }; + 26EE8B1E179B67FA006781F3 /* MLNotificationManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MLNotificationManager.m; path = Classes/MLNotificationManager.m; sourceTree = ""; }; + 26F9794C1ACAC73A0008E005 /* MLContactCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MLContactCell.xib; sourceTree = ""; }; + 26FC618B24EB2BEE0094C302 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "external/zh-Hant.lproj/Main.strings"; sourceTree = ""; }; + 26FC618E24EB2BF10094C302 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "external/zh-Hant.lproj/Settings.strings"; sourceTree = ""; }; + 26FC619024EB2BF40094C302 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "external/zh-Hant.lproj/iosShare.strings"; sourceTree = ""; }; + 26FC619124EB6C220094C302 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = external/en.lproj/Main.strings; sourceTree = ""; }; + 26FC619324EB6C250094C302 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = external/en.lproj/Settings.strings; sourceTree = ""; }; + 26FC619524EB6C270094C302 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = external/en.lproj/iosShare.strings; sourceTree = ""; }; + 26FE3BC91C61A6C3003CC230 /* MLResizingTextView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLResizingTextView.h; sourceTree = ""; }; + 26FE3BCA1C61A6C3003CC230 /* MLResizingTextView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLResizingTextView.m; sourceTree = ""; }; + 29250DA62DD2322383585B2B /* Pods_MonalUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MonalUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 29B97316FDCFA39411CA2CEA /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 2B7A5555D807EE78C95217FD /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2C59BAF969550DFAC27E5F2B /* Pods_MonalXMPPUnitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MonalXMPPUnitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2E5021A8D40FCC591D952104 /* Pods-NotificaionService.alpha-ios.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificaionService.alpha-ios.xcconfig"; path = "Target Support Files/Pods-NotificaionService/Pods-NotificaionService.alpha-ios.xcconfig"; sourceTree = ""; }; + 32CA4F630368D1EE00C91783 /* MonalSourceCodePrefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MonalSourceCodePrefix.pch; sourceTree = ""; }; + 34BC08112C5E9BE30099FB85 /* ContentUnavailableShimView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentUnavailableShimView.swift; sourceTree = ""; }; + 34E58B4A2C68E7BC009A1634 /* ContactsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = ""; }; + 3872091F251EDE07001837EB /* MLXEPSlashMeHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLXEPSlashMeHandler.h; sourceTree = ""; }; + 38720921251EDE07001837EB /* MLXEPSlashMeHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLXEPSlashMeHandler.m; sourceTree = ""; }; + 389E298925E901CA009A5268 /* MLAudioRecoderManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLAudioRecoderManager.m; sourceTree = ""; }; + 389E298B25E901CA009A5268 /* MLAudioRecoderManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLAudioRecoderManager.h; sourceTree = ""; }; + 39B989B9775C0725A810D271 /* Pods-MonalUITests.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalUITests.adhoc.xcconfig"; path = "Target Support Files/Pods-MonalUITests/Pods-MonalUITests.adhoc.xcconfig"; sourceTree = ""; }; + 39DB4C9159DA578D1A34990D /* Pods-monalxmpp.alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-monalxmpp.alpha.xcconfig"; path = "Target Support Files/Pods-monalxmpp/Pods-monalxmpp.alpha.xcconfig"; sourceTree = ""; }; + 3D06A514281FFCC000DDAE90 /* NotificationDebugging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugging.swift; sourceTree = ""; }; + 3D27D955290B0BB60014748B /* AddContactMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactMenu.swift; sourceTree = ""; }; + 3D27D957290B0BC80014748B /* ContactRequestsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestsMenu.swift; sourceTree = ""; }; + 3D5A91412842B4AE008CE57E /* MemberList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberList.swift; sourceTree = ""; }; + 3D631822294BAB1D00026BE7 /* ContactPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPicker.swift; sourceTree = ""; }; + 3D65B78C27234B74005A30F4 /* ContactDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactDetails.swift; sourceTree = ""; }; + 3D65B790272350F0005A30F4 /* SwiftuiHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftuiHelpers.swift; sourceTree = ""; }; + 3D7D352228626CB80042C5E5 /* LoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingOverlay.swift; sourceTree = ""; }; + 3D85E586282AE523006F5B3A /* OmemoQrCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmemoQrCodeView.swift; sourceTree = ""; }; + 3D88BB76295BB6DC00FB30BA /* CreateGroupMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupMenu.swift; sourceTree = ""; }; + 3D8AAFBF5B865907983E9F59 /* Pods-another.im.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-another.im.beta.xcconfig"; path = "Target Support Files/Pods-another.im/Pods-another.im.beta.xcconfig"; sourceTree = ""; }; + 3DC5035B2822F5220064C8A7 /* OmemoKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmemoKeysView.swift; sourceTree = ""; }; + 3EB7A7084FA9A8F68A3D251C /* Pods-MonalXMPPUnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalXMPPUnitTests.debug.xcconfig"; path = "Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests.debug.xcconfig"; sourceTree = ""; }; + 4049F81F60EA5B7A57A4E9C6 /* Pods-NotificationService.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.beta.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.beta.xcconfig"; sourceTree = ""; }; + 43FFAD161EF5A0B1CB149814 /* Pods-shareSheet.alpha-ios.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.alpha-ios.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.alpha-ios.xcconfig"; sourceTree = ""; }; + 4862C3A0242FB4F709B8F3FF /* Pods-MonalXMPPUnitTests.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalXMPPUnitTests.beta.xcconfig"; path = "Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests.beta.xcconfig"; sourceTree = ""; }; + 4A614910EEF29D66DD4B37E3 /* Pods-NotificaionService.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificaionService.adhoc.xcconfig"; path = "Target Support Files/Pods-NotificaionService/Pods-NotificaionService.adhoc.xcconfig"; sourceTree = ""; }; + 4E53581DEF864B229A09FA61 /* Pods-MonalXMPPUnitTests.alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalXMPPUnitTests.alpha.xcconfig"; path = "Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests.alpha.xcconfig"; sourceTree = ""; }; + 540A3FAC24D674BD0008965D /* MLSQLite.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLSQLite.m; sourceTree = ""; }; + 540BD0D124D8D1F20087A743 /* IPC.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IPC.h; sourceTree = ""; }; + 540BD0D324D8D1FF0087A743 /* IPC.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IPC.m; sourceTree = ""; }; + 540E139F24CDCDB30038FDA0 /* MLProcessLock.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLProcessLock.m; sourceTree = ""; }; + 540E13A124CDCE3B0038FDA0 /* MLProcessLock.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLProcessLock.h; sourceTree = ""; }; + 54179CBF251CBAF9008F398E /* XMPPStanza.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = XMPPStanza.m; sourceTree = ""; }; + 54179CC2251CBB2B008F398E /* XMPPStanza.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XMPPStanza.h; sourceTree = ""; }; + 541B6AB2262BC9040038B936 /* MLStream.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLStream.m; sourceTree = ""; }; + 541B6AB3262BC9040038B936 /* MLStream.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLStream.h; sourceTree = ""; }; + 541E4CBB254AA08600FD7B28 /* MLSQLite.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLSQLite.h; sourceTree = ""; }; + 541E4CBD254AA0B600FD7B28 /* MLHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLHandler.h; sourceTree = ""; }; + 541E4CBF254AA0E700FD7B28 /* MLHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLHandler.m; sourceTree = ""; }; + 541E4CC3254D369100FD7B28 /* MLPubSubProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLPubSubProcessor.h; sourceTree = ""; }; + 541E4CC6254D370100FD7B28 /* MLPubSubProcessor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLPubSubProcessor.m; sourceTree = ""; }; + 542CF3FD2763314E002C3710 /* hsluv.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = hsluv.c; sourceTree = ""; }; + 542CF3FE2763314F002C3710 /* hsluv.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = hsluv.h; sourceTree = ""; }; + 54391BB9273499E80050DAFB /* UIColor+Extension.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIColor+Extension.h"; sourceTree = ""; }; + 54391BBA273499E80050DAFB /* UIColor+Extension.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIColor+Extension.m"; sourceTree = ""; }; + 544656BA2534910D006B2953 /* XMPPDataForm.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = XMPPDataForm.m; sourceTree = ""; }; + 544656BC25349133006B2953 /* XMPPDataForm.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XMPPDataForm.h; sourceTree = ""; }; + 54507CE4255D8C14007092F4 /* MLFiletransfer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLFiletransfer.m; sourceTree = ""; }; + 54507CE7255D8C47007092F4 /* MLFiletransfer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLFiletransfer.h; sourceTree = ""; }; + 5455BC3024EB776B0024D80F /* MLUDPLogger.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLUDPLogger.m; sourceTree = ""; }; + 5455BC3224EB776E0024D80F /* MLUDPLogger.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLUDPLogger.h; sourceTree = ""; }; + 54A22D2426185C2900B56EAD /* MLNotificationQueue.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLNotificationQueue.m; sourceTree = ""; }; + 54A22D2C26185E7E00B56EAD /* MLNotificationQueue.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLNotificationQueue.h; sourceTree = ""; }; + 54E594BA2523C34900E4172B /* MLPubSub.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLPubSub.h; sourceTree = ""; }; + 54E594BC2523C34A00E4172B /* MLPubSub.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLPubSub.m; sourceTree = ""; }; + 54F0B81828231690003664BD /* WelcomeLogIn.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeLogIn.swift; sourceTree = ""; }; + 54F0B81B282316F5003664BD /* RegisterAccount.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegisterAccount.swift; sourceTree = ""; }; + 59F4A459FBC6040A0F8CCAF3 /* Pods-NotificaionService.alpha-catalyst.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificaionService.alpha-catalyst.xcconfig"; path = "Target Support Files/Pods-NotificaionService/Pods-NotificaionService.alpha-catalyst.xcconfig"; sourceTree = ""; }; + 5B9C86E0A568734587FE9BA2 /* Pods_monalxmpp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_monalxmpp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5BACDACCFE405FE0C903C897 /* Pods-MonalUITests.alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalUITests.alpha.xcconfig"; path = "Target Support Files/Pods-MonalUITests/Pods-MonalUITests.alpha.xcconfig"; sourceTree = ""; }; + 6015D382ABCE0D788029D7A3 /* Pods-shareSheet.alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.alpha.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.alpha.xcconfig"; sourceTree = ""; }; + 6142E73A9912F6E327A8CD14 /* Pods-Monal Tests.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal Tests.adhoc.xcconfig"; path = "Target Support Files/Pods-Monal Tests/Pods-Monal Tests.adhoc.xcconfig"; sourceTree = ""; }; + 66387B079AB74C687D46FD0A /* Pods-Monal.alpha-catalyst.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.alpha-catalyst.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.alpha-catalyst.xcconfig"; sourceTree = ""; }; + 671D139EE64DB6AD9E1D8108 /* Pods-NotificationService.alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.alpha.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.alpha.xcconfig"; sourceTree = ""; }; + 681C751BDE486C4E3A43CF72 /* Pods-MonalXMPPUnitTests.alpha-catalyst.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalXMPPUnitTests.alpha-catalyst.xcconfig"; path = "Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests.alpha-catalyst.xcconfig"; sourceTree = ""; }; + 6A6D58F695CDFAF204F3B3EB /* Pods-Monal Tests.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal Tests.appstore.xcconfig"; path = "Target Support Files/Pods-Monal Tests/Pods-Monal Tests.appstore.xcconfig"; sourceTree = ""; }; + 6BCB9FB4EBEA3735D24A44DF /* Pods-shareSheet.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.beta.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.beta.xcconfig"; sourceTree = ""; }; + 72CBE47E31AF93F7357B1202 /* Pods-monalxmpp.alpha-ios.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-monalxmpp.alpha-ios.xcconfig"; path = "Target Support Files/Pods-monalxmpp/Pods-monalxmpp.alpha-ios.xcconfig"; sourceTree = ""; }; + 797F93A1B3C6B4735F2ABE7D /* Pods-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.debug.xcconfig"; sourceTree = ""; }; + 79A6AA4819B69B5FFFA28236 /* Pods-NotificationService.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.appstore.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.appstore.xcconfig"; sourceTree = ""; }; + 7D281334DB441077E42E3E89 /* Pods-another.im.appstore-quicksy.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-another.im.appstore-quicksy.xcconfig"; path = "Target Support Files/Pods-another.im/Pods-another.im.appstore-quicksy.xcconfig"; sourceTree = ""; }; + 7D6715099247A9CCC180EE30 /* Pods-MonalUITests.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalUITests.beta.xcconfig"; path = "Target Support Files/Pods-MonalUITests/Pods-MonalUITests.beta.xcconfig"; sourceTree = ""; }; + 7E995F062CEAC4B8005B30EE /* another.im.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = another.im.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7E995F202CEAC5D2005B30EE /* AnotherIMApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnotherIMApp.swift; sourceTree = ""; }; + 7E995F212CEAC5D2005B30EE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 7E995F222CEAC5D2005B30EE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 7E995F282CEAC672005B30EE /* ASN1Decoder.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ASN1Decoder.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7FA9582E4CC566FE5466C557 /* Pods-Monal.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.debug.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.debug.xcconfig"; sourceTree = ""; }; + 840E23C828ADA56900A7FAC9 /* MLUploadQueueCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLUploadQueueCell.m; sourceTree = ""; }; + 840E23C928ADA56900A7FAC9 /* MLUploadQueueCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLUploadQueueCell.h; sourceTree = ""; }; + 8414ADFA2A7ABAC900EFFCCC /* LibMonalRustSwiftBridge */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = LibMonalRustSwiftBridge; path = ../rust/LibMonalRustSwiftBridge; sourceTree = ""; }; + 841898AB2957DBAC00FEC77D /* RichAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichAlert.swift; sourceTree = ""; }; + 8418B5582C7EBD39006FAF60 /* ActiveChatsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ActiveChatsViewController.h; sourceTree = ""; }; + 8418B55B2C7EC6B7006FAF60 /* Quicksy_Country.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Quicksy_Country.h; sourceTree = ""; }; + 8418B55C2C7EC6B7006FAF60 /* Quicksy_Country.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Quicksy_Country.m; sourceTree = ""; }; + 8418B5612C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "HelperTools+Quicksy_CountryCodes.h"; sourceTree = ""; }; + 8418B5622C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "HelperTools+Quicksy_CountryCodes.m"; sourceTree = ""; }; + 841B6F19297B18720074F9B7 /* AccountPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountPicker.swift; sourceTree = ""; }; + 841B6F1B297B3CFC0074F9B7 /* AVCallUI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AVCallUI.swift; sourceTree = ""; }; + 841EE42F2A426F2300D3AF14 /* MLCrashReporter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = MLCrashReporter.m; path = Classes/MLCrashReporter.m; sourceTree = ""; }; + 841EE4312A426F3D00D3AF14 /* MLCrashReporter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = MLCrashReporter.h; path = Classes/MLCrashReporter.h; sourceTree = ""; }; + 8420EA9A2915E4FE0038FF40 /* OmemoState.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OmemoState.h; sourceTree = ""; }; + 8420EA9C2915E5100038FF40 /* OmemoState.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OmemoState.m; sourceTree = ""; }; + 842790842A32D16C005C18CC /* CallSounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CallSounds; sourceTree = ""; }; + 843AD3AA2AA55CE20036844D /* MLOgHtmlParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MLOgHtmlParser.swift; path = Classes/MLOgHtmlParser.swift; sourceTree = ""; }; + 8441EFF82921B53500E851E9 /* BackgroundSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundSettings.swift; sourceTree = ""; }; + 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLDelayableTimer.m; sourceTree = ""; }; + 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLDelayableTimer.h; sourceTree = ""; }; + 845836B92C49F36300B11EC5 /* Quicksy Launch Screen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = "Quicksy Launch Screen.storyboard"; path = "Monal-iOS/Quicksy Launch Screen.storyboard"; sourceTree = ""; }; + 845D636A2AD4AEDA0066EFFB /* MediaViewer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = ""; }; + 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPlaceholder.swift; sourceTree = ""; }; + 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLPlaceholderViewController.m; sourceTree = ""; }; + 848717F1295ED64500B8D288 /* MLCall.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MLCall.m; path = Classes/MLCall.m; sourceTree = SOURCE_ROOT; }; + 848717F2295ED64500B8D288 /* MLCall.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MLCall.h; path = Classes/MLCall.h; sourceTree = SOURCE_ROOT; }; + 848904A8289C82C30097E19C /* SCRAM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCRAM.m; sourceTree = ""; }; + 848904AA289C82DB0097E19C /* SCRAM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCRAM.h; sourceTree = ""; }; + 848C73DF2BDF2014007035C9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 849248482AD4CEC400986C1A /* ZoomableContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomableContainer.swift; sourceTree = ""; }; + 849A53E3287135B2007E941A /* MLVoIPProcessor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = MLVoIPProcessor.m; path = Classes/MLVoIPProcessor.m; sourceTree = SOURCE_ROOT; }; + 849A53E5287135D7007E941A /* MLVoIPProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = MLVoIPProcessor.h; path = Classes/MLVoIPProcessor.h; sourceTree = SOURCE_ROOT; }; + 84BBAEC92C42D272009492E2 /* Quicksy_RegisterAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quicksy_RegisterAccount.swift; sourceTree = ""; }; + 84C1CD4F2A8C764D007076ED /* SwiftHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftHelpers.swift; sourceTree = ""; }; + 84C1CD512A8F617F007076ED /* MLStreamRedirect.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLStreamRedirect.m; sourceTree = ""; }; + 84C1CD532A8F6196007076ED /* MLStreamRedirect.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLStreamRedirect.h; sourceTree = ""; }; + 84D31CE528653B83006D7926 /* WebRTCClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WebRTCClient.swift; path = Classes/WebRTCClient.swift; sourceTree = SOURCE_ROOT; }; + 84F194C32C0FE70900F0A994 /* es-AR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-AR"; path = "external/es-AR.lproj/Main.strings"; sourceTree = ""; }; + 84F194C42C0FE74500F0A994 /* es-AR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-AR"; path = "external/es-AR.lproj/Settings.strings"; sourceTree = ""; }; + 84F194C52C0FE78B00F0A994 /* es-AR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-AR"; path = "external/es-AR.lproj/iosShare.strings"; sourceTree = ""; }; + 84F194C62C0FE79000F0A994 /* es-AR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-AR"; path = "external/es-AR.lproj/Localizable.strings"; sourceTree = ""; }; + 84FC37542897521400634E3E /* snprintf.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = snprintf.m; sourceTree = ""; }; + 84FC37562897523500634E3E /* metamacros.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = metamacros.h; sourceTree = ""; }; + 84FC375828981A5600634E3E /* PasswordMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordMigration.swift; sourceTree = ""; }; + 86CF04FDE084C646CA14B774 /* Pods-Monal Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal Tests.debug.xcconfig"; path = "Target Support Files/Pods-Monal Tests/Pods-Monal Tests.debug.xcconfig"; sourceTree = ""; }; + 8C40963CED187B2F1B4B88F7 /* Pods_Monal.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Monal.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8D1107310486CEB800E47090 /* Monal-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Monal-Info.plist"; plistStructureDefinitionIdentifier = "com.apple.xcode.plist.structure-definition.iphone.info-plist"; sourceTree = ""; }; + 8F1488206B764014DD7EC92A /* Pods_shareSheet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_shareSheet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9162E3E9FCE2280BF75F5102 /* Pods-Monal.alpha-ios.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.alpha-ios.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.alpha-ios.xcconfig"; sourceTree = ""; }; + 952EBC7F2BAF72F300183DBF /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = ""; }; + 9705AFFB59AF72A9B79C1D7B /* Pods-MonalXMPPUnitTests.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalXMPPUnitTests.adhoc.xcconfig"; path = "Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests.adhoc.xcconfig"; sourceTree = ""; }; + 9760CF4718351300C4256921 /* Pods-shareSheet.appstore-quicksy.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.appstore-quicksy.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.appstore-quicksy.xcconfig"; sourceTree = ""; }; + 9899D670570190DCBE9EEDDB /* Pods-monalxmpp.appstore-quicksy.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-monalxmpp.appstore-quicksy.xcconfig"; path = "Target Support Files/Pods-monalxmpp/Pods-monalxmpp.appstore-quicksy.xcconfig"; sourceTree = ""; }; + A2ED40D3515305509E3E166C /* Pods-MonalUITests.alpha-catalyst.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalUITests.alpha-catalyst.xcconfig"; path = "Target Support Files/Pods-MonalUITests/Pods-MonalUITests.alpha-catalyst.xcconfig"; sourceTree = ""; }; + A4C686567AC126CDDFB1BE44 /* Pods-NotificaionService.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificaionService.beta.xcconfig"; path = "Target Support Files/Pods-NotificaionService/Pods-NotificaionService.beta.xcconfig"; sourceTree = ""; }; + AA697C1F9B9637B86665DFF1 /* Pods-NotificationService.appstore-quicksy.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.appstore-quicksy.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.appstore-quicksy.xcconfig"; sourceTree = ""; }; + AAFC73E987D41BA8D91E9F95 /* Pods-monalxmpp.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-monalxmpp.appstore.xcconfig"; path = "Target Support Files/Pods-monalxmpp/Pods-monalxmpp.appstore.xcconfig"; sourceTree = ""; }; + AD0E234056402EE91A36D628 /* Pods-Monal.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.adhoc.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.adhoc.xcconfig"; sourceTree = ""; }; + B55DCA2ABBDB6E635D46D69A /* Pods-monalxmpp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-monalxmpp.debug.xcconfig"; path = "Target Support Files/Pods-monalxmpp/Pods-monalxmpp.debug.xcconfig"; sourceTree = ""; }; + B58835E4BBDCCB6BE1E8F0AE /* Pods-MonalXMPPUnitTests.appstore-quicksy.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalXMPPUnitTests.appstore-quicksy.xcconfig"; path = "Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests.appstore-quicksy.xcconfig"; sourceTree = ""; }; + B8155E63F8DE80FF36D0B3B7 /* Pods-NotificationService.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.adhoc.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.adhoc.xcconfig"; sourceTree = ""; }; + BC9E05245CF07072A35AE126 /* Pods-another.im.alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-another.im.alpha.xcconfig"; path = "Target Support Files/Pods-another.im/Pods-another.im.alpha.xcconfig"; sourceTree = ""; }; + BFA9EFD7A8064201C81F52CF /* Pods-Monal.alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.alpha.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.alpha.xcconfig"; sourceTree = ""; }; + C10490482612ED2F0054AC9E /* MLEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLEmoji.swift; sourceTree = ""; }; + C10490E22612F3D00054AC9E /* MLCrypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLCrypto.swift; sourceTree = ""; }; + C10490EA2612F3E00054AC9E /* EncryptedPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedPayload.swift; sourceTree = ""; }; + C1049186261301530054AC9E /* MonalXMPPUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MonalXMPPUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C1049188261301530054AC9E /* MonalXMPPUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonalXMPPUnitTests.swift; sourceTree = ""; }; + C104918A261301530054AC9E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C1049198261301710054AC9E /* MLCryptoTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLCryptoTest.swift; sourceTree = ""; }; + C114D13C2B15B903000FB99F /* ContactEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactEntry.swift; sourceTree = ""; }; + C12436122434AB5D00B8F074 /* MLAttributedLabel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLAttributedLabel.h; sourceTree = ""; }; + C12436132434AB5D00B8F074 /* MLAttributedLabel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLAttributedLabel.m; sourceTree = ""; }; + C132EA9426C92DD900BB9A67 /* pa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pa; path = external/pa.lproj/Main.strings; sourceTree = ""; }; + C132EA9626C92DD900BB9A67 /* pa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pa; path = external/pa.lproj/Settings.strings; sourceTree = ""; }; + C132EA9926C92DDA00BB9A67 /* pa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pa; path = external/pa.lproj/Localizable.strings; sourceTree = ""; }; + C132EA9B26C92E9000BB9A67 /* pa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pa; path = external/pa.lproj/iosShare.strings; sourceTree = ""; }; + C13CF819261A00D0005452E5 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "external/zh-Hans.lproj/Main.strings"; sourceTree = ""; }; + C13CF81B261A00FD005452E5 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "external/zh-Hans.lproj/Settings.strings"; sourceTree = ""; }; + C13CF81D261A00FE005452E5 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "external/zh-Hans.lproj/iosShare.strings"; sourceTree = ""; }; + C13CF81E261A00FE005452E5 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "external/zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + C13E640625BD405700763D6F /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "external/zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + C13E640825BD406600763D6F /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "external/pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + C13E640925BD406700763D6F /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "external/pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; + C1414E9B24312F0100948788 /* MLChatMapsCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLChatMapsCell.h; sourceTree = ""; }; + C1414E9C24312F0100948788 /* MLChatMapsCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLChatMapsCell.m; sourceTree = ""; }; + C15489B825680BBE00BBA2F0 /* MLQRCodeScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MLQRCodeScanner.swift; sourceTree = ""; }; + C1567E3528255C64006E9637 /* Monal.macos.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Monal.macos.entitlements; sourceTree = ""; }; + C1567E3628255C64006E9637 /* Monal.ios.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Monal.ios.entitlements; sourceTree = ""; }; + C1567E3728255D02006E9637 /* NotificationService.ios.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = NotificationService.ios.entitlements; sourceTree = ""; }; + C1567E3828255D02006E9637 /* NotificationService.macos.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = NotificationService.macos.entitlements; sourceTree = ""; }; + C158D3FE25A0AB810005AA40 /* MLMucProcessor.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLMucProcessor.h; sourceTree = ""; }; + C158D41225A0AC630005AA40 /* MLMucProcessor.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLMucProcessor.m; sourceTree = ""; }; + C15A4E5B279D2AC60055CD11 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = external/ko.lproj/Main.strings; sourceTree = ""; }; + C15A4E5D279D2AC70055CD11 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = external/ko.lproj/Settings.strings; sourceTree = ""; }; + C15A4E5F279D2AC80055CD11 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = external/ko.lproj/iosShare.strings; sourceTree = ""; }; + C15A4E60279D2AC80055CD11 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = external/ko.lproj/Localizable.strings; sourceTree = ""; }; + C15D504824C727CC002F75BB /* MLLogFileManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLLogFileManager.h; sourceTree = ""; }; + C15D504A24C727CC002F75BB /* MLLogFileManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLLogFileManager.m; sourceTree = ""; }; + C1613B572520723C0062C0C2 /* MLBasePaser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLBasePaser.h; sourceTree = ""; }; + C1613B592520723C0062C0C2 /* MLBasePaser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLBasePaser.m; sourceTree = ""; }; + C16D18332792A4AF00F869A0 /* DataLayerMigrations.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DataLayerMigrations.h; sourceTree = ""; }; + C16D18342792A4AF00F869A0 /* DataLayerMigrations.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DataLayerMigrations.m; sourceTree = ""; }; + C176F1EB2AF11C31002034E5 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.0.sdk/System/Library/Frameworks/UserNotifications.framework; sourceTree = DEVELOPER_DIR; }; + C1850E6625F37EC0003D506A /* Monal Tests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Monal Tests-Bridging-Header.h"; sourceTree = ""; }; + C1850EB525F38A2D003D506A /* .xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = .xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C1850EB725F38A2D003D506A /* MonalUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonalUITests.swift; sourceTree = ""; }; + C1850EB925F38A2D003D506A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C1850EC525F3C5EB003D506A /* TestHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelper.swift; sourceTree = ""; }; + C18967C62B81F61B0073C7C5 /* ChannelMemberList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMemberList.swift; sourceTree = ""; }; + C18E7578245E8AE900AE8FB7 /* MLPipe.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLPipe.h; sourceTree = ""; }; + C18E7579245E8AE900AE8FB7 /* MLPipe.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLPipe.m; sourceTree = ""; }; + C1943A4A25309A9D0036172F /* MLReloadCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLReloadCell.h; sourceTree = ""; }; + C1943A4B25309A9D0036172F /* MLReloadCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLReloadCell.m; sourceTree = ""; }; + C1A80DA224D9552400B99E01 /* MLChatViewHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLChatViewHelper.h; sourceTree = ""; }; + C1A80DA324D9552400B99E01 /* MLChatViewHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLChatViewHelper.m; sourceTree = ""; }; + C1AAC3E224B5EF4100BB15D6 /* HelperTools.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = HelperTools.h; sourceTree = ""; }; + C1AAC3E324B5EF4100BB15D6 /* HelperTools.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = HelperTools.m; sourceTree = ""; }; + C1B940E826144AF400E9D290 /* MonalXMPPUnitTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MonalXMPPUnitTests-Bridging-Header.h"; sourceTree = ""; }; + C1B940E926144AF500E9D290 /* AESGCMTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AESGCMTest.m; sourceTree = ""; }; + C1C8394E24F0EDFD00BBCF17 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = external/ar.lproj/Main.strings; sourceTree = ""; }; + C1C8395024F0EE0300BBCF17 /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "external/zh-Hant-HK.lproj/Main.strings"; sourceTree = ""; }; + C1C8395224F0EE0A00BBCF17 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = external/cs.lproj/Main.strings; sourceTree = ""; }; + C1C8395424F0EE0D00BBCF17 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = external/da.lproj/Main.strings; sourceTree = ""; }; + C1C8395624F0EE1800BBCF17 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = external/sv.lproj/Main.strings; sourceTree = ""; }; + C1C8395824F0EE1B00BBCF17 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = external/fa.lproj/Main.strings; sourceTree = ""; }; + C1C8395A24F0EE2000BBCF17 /* ne */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ne; path = external/ne.lproj/Main.strings; sourceTree = ""; }; + C1C8395C24F0EE2400BBCF17 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = external/fi.lproj/Main.strings; sourceTree = ""; }; + C1C8395E24F0EE2C00BBCF17 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = external/el.lproj/Main.strings; sourceTree = ""; }; + C1C8395F24F0EE6700BBCF17 /* nb-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nb-NO"; path = "external/nb-NO.lproj/Localizable.strings"; sourceTree = ""; }; + C1C8396324F0EEBA00BBCF17 /* nb-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nb-NO"; path = "external/nb-NO.lproj/Main.strings"; sourceTree = ""; }; + C1C8397824F0EEF900BBCF17 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = external/da.lproj/Settings.strings; sourceTree = ""; }; + C1C8397A24F0EF0000BBCF17 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = external/cs.lproj/Settings.strings; sourceTree = ""; }; + C1C8397C24F0EF0400BBCF17 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = external/fi.lproj/Settings.strings; sourceTree = ""; }; + C1C8397E24F0EF0A00BBCF17 /* ne */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ne; path = external/ne.lproj/Settings.strings; sourceTree = ""; }; + C1C8398024F0EF1500BBCF17 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = external/ar.lproj/Settings.strings; sourceTree = ""; }; + C1C8398224F0EF4700BBCF17 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = external/el.lproj/Settings.strings; sourceTree = ""; }; + C1C8398324F0EF4A00BBCF17 /* nb-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nb-NO"; path = "external/nb-NO.lproj/Settings.strings"; sourceTree = ""; }; + C1C8398524F0EF4F00BBCF17 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = external/fa.lproj/Settings.strings; sourceTree = ""; }; + C1C8398724F0EF5600BBCF17 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = external/tr.lproj/Settings.strings; sourceTree = ""; }; + C1C8398924F0EF5A00BBCF17 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = external/sv.lproj/Settings.strings; sourceTree = ""; }; + C1C8399E24F0EF9B00BBCF17 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = external/ar.lproj/iosShare.strings; sourceTree = ""; }; + C1C839A024F0EFA000BBCF17 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = external/cs.lproj/iosShare.strings; sourceTree = ""; }; + C1C839A224F0EFA400BBCF17 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = external/da.lproj/iosShare.strings; sourceTree = ""; }; + C1C839A424F0EFAC00BBCF17 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = external/fi.lproj/iosShare.strings; sourceTree = ""; }; + C1C839A624F0EFB000BBCF17 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = external/el.lproj/iosShare.strings; sourceTree = ""; }; + C1C839A824F0EFB400BBCF17 /* ne */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ne; path = external/ne.lproj/iosShare.strings; sourceTree = ""; }; + C1C839A924F0EFB700BBCF17 /* nb-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nb-NO"; path = "external/nb-NO.lproj/iosShare.strings"; sourceTree = ""; }; + C1C839AB24F0EFB900BBCF17 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = external/fa.lproj/iosShare.strings; sourceTree = ""; }; + C1C839AD24F0EFC900BBCF17 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = external/sv.lproj/iosShare.strings; sourceTree = ""; }; + C1C839AF24F0EFD200BBCF17 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = external/tr.lproj/iosShare.strings; sourceTree = ""; }; + C1C839C024F1252B00BBCF17 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = external/tr.lproj/Main.strings; sourceTree = ""; }; + C1C839C424F1254200BBCF17 /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "external/zh-Hant-HK.lproj/Settings.strings"; sourceTree = ""; }; + C1C839C824F1255600BBCF17 /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "external/zh-Hant-HK.lproj/iosShare.strings"; sourceTree = ""; }; + C1C839DA24F15DF800BBCF17 /* MLOMEMO.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLOMEMO.h; sourceTree = ""; }; + C1C839DC24F15DF800BBCF17 /* MLOMEMO.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLOMEMO.m; sourceTree = ""; }; + C1E4654724EE517000CA5AAF /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = external/de.lproj/Localizable.strings; sourceTree = ""; }; + C1E4654924EE51EC00CA5AAF /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = external/Base.lproj/Localizable.strings; sourceTree = ""; }; + C1E4654A24EE520D00CA5AAF /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = external/fr.lproj/Localizable.strings; sourceTree = ""; }; + C1E4654B24EE520D00CA5AAF /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = external/ru.lproj/Localizable.strings; sourceTree = ""; }; + C1E4654C24EE520D00CA5AAF /* ne */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ne; path = external/ne.lproj/Localizable.strings; sourceTree = ""; }; + C1E4654D24EE520D00CA5AAF /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = external/pl.lproj/Localizable.strings; sourceTree = ""; }; + C1E4654E24EE520D00CA5AAF /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = external/he.lproj/Localizable.strings; sourceTree = ""; }; + C1E4654F24EE520D00CA5AAF /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "external/zh-Hant-HK.lproj/Localizable.strings"; sourceTree = ""; }; + C1E4655024EE520D00CA5AAF /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = external/ro.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655124EE520D00CA5AAF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = external/es.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655224EE520D00CA5AAF /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = external/ar.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655324EE520E00CA5AAF /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = external/fi.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655424EE520E00CA5AAF /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = external/sv.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655524EE520E00CA5AAF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = external/ja.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655624EE520E00CA5AAF /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = external/uk.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655724EE520E00CA5AAF /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = external/tr.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655824EE520E00CA5AAF /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = external/hi.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655924EE520E00CA5AAF /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "external/es-419.lproj/Localizable.strings"; sourceTree = ""; }; + C1E4655A24EE520E00CA5AAF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = external/it.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655B24EE520F00CA5AAF /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = external/vi.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655C24EE520F00CA5AAF /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = external/da.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655D24EE520F00CA5AAF /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = external/nl.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655E24EE520F00CA5AAF /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = external/cs.lproj/Localizable.strings; sourceTree = ""; }; + C1E4655F24EE520F00CA5AAF /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = external/el.lproj/Localizable.strings; sourceTree = ""; }; + C1E4656024EE520F00CA5AAF /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = external/fa.lproj/Localizable.strings; sourceTree = ""; }; + C1E856A628DECF5F00B104E9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = external/eu.lproj/Main.strings; sourceTree = ""; }; + C1E856A728DECF5F00B104E9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = external/eu.lproj/Settings.strings; sourceTree = ""; }; + C1E856A828DECF5F00B104E9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = external/eu.lproj/iosShare.strings; sourceTree = ""; }; + C1E856A928DECF5F00B104E9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = external/eu.lproj/Localizable.strings; sourceTree = ""; }; + C1E8A7F62B8E47C300760220 /* EditGroupSubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditGroupSubject.swift; sourceTree = ""; }; + C1F0AD15288BCE6F00BB0182 /* Monal.Alpha.ios.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Monal.Alpha.ios.entitlements; sourceTree = ""; }; + C1F5C7A72775DA000001F295 /* MLContactSoftwareVersionInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLContactSoftwareVersionInfo.h; sourceTree = ""; }; + C1F5C7A82775DA000001F295 /* MLContactSoftwareVersionInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLContactSoftwareVersionInfo.m; sourceTree = ""; }; + C1F5C7AB2777621B0001F295 /* ContactResources.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactResources.swift; sourceTree = ""; }; + D02192F22C89BB3800202A59 /* BlockedUsers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsers.swift; sourceTree = ""; }; + D0FA79B02C7E5C7400216D2A /* ServerDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetails.swift; sourceTree = ""; }; + D310A8387B2EB10761312F77 /* Pods-NotificaionService.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificaionService.appstore.xcconfig"; path = "Target Support Files/Pods-NotificaionService/Pods-NotificaionService.appstore.xcconfig"; sourceTree = ""; }; + D8D2595B2BE453296E59F1AF /* Pods-MonalUITests.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalUITests.appstore.xcconfig"; path = "Target Support Files/Pods-MonalUITests/Pods-MonalUITests.appstore.xcconfig"; sourceTree = ""; }; + DC5DA2C9782510B3433FD50D /* Pods-monalxmpp.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-monalxmpp.beta.xcconfig"; path = "Target Support Files/Pods-monalxmpp/Pods-monalxmpp.beta.xcconfig"; sourceTree = ""; }; + E06DB4446BAE2F9C0192D055 /* Pods-monalxmpp.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-monalxmpp.adhoc.xcconfig"; path = "Target Support Files/Pods-monalxmpp/Pods-monalxmpp.adhoc.xcconfig"; sourceTree = ""; }; + E89DD31C25C6626300925F62 /* MLFileTransferFileViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLFileTransferFileViewController.h; sourceTree = ""; }; + E89DD31E25C6626300925F62 /* MLFileTransferDataCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLFileTransferDataCell.h; sourceTree = ""; }; + E89DD31F25C6626300925F62 /* MLFileTransferVideoCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLFileTransferVideoCell.h; sourceTree = ""; }; + E89DD32025C6626300925F62 /* MLFileTransferDataCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLFileTransferDataCell.m; sourceTree = ""; }; + E89DD32125C6626300925F62 /* MLFileTransferVideoCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLFileTransferVideoCell.m; sourceTree = ""; }; + E89DD32225C6626300925F62 /* MLFileTransferFileViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLFileTransferFileViewController.m; sourceTree = ""; }; + E89DD32325C6626400925F62 /* MLFileTransferTextCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLFileTransferTextCell.h; sourceTree = ""; }; + E89DD32425C6626400925F62 /* MLFileTransferTextCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLFileTransferTextCell.m; sourceTree = ""; }; + E8CF9CBF26249640001A1952 /* MLSettingsAboutViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLSettingsAboutViewController.h; sourceTree = ""; }; + E8CF9CC026249640001A1952 /* MLSettingsAboutViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLSettingsAboutViewController.m; sourceTree = ""; }; + E8DED05F25388BE8003167FF /* MLSearchViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MLSearchViewController.h; sourceTree = ""; }; + E8DED06125388BE8003167FF /* MLSearchViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLSearchViewController.m; sourceTree = ""; }; + F04FE24284F1563D80523B52 /* Pods-monalxmpp.alpha-catalyst.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-monalxmpp.alpha-catalyst.xcconfig"; path = "Target Support Files/Pods-monalxmpp/Pods-monalxmpp.alpha-catalyst.xcconfig"; sourceTree = ""; }; + F2082C5D72E8D7D49B31FBEE /* Pods-another.im.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-another.im.appstore.xcconfig"; path = "Target Support Files/Pods-another.im/Pods-another.im.appstore.xcconfig"; sourceTree = ""; }; + F29121F912380F72CCE51747 /* Pods_another_im.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_another_im.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F7506FDE7A78EB0CAB14FF60 /* Pods-MonalUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MonalUITests.debug.xcconfig"; path = "Target Support Files/Pods-MonalUITests/Pods-MonalUITests.debug.xcconfig"; sourceTree = ""; }; + F8ACC07B95446BB8052933BF /* Pods-another.im.adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-another.im.adhoc.xcconfig"; path = "Target Support Files/Pods-another.im/Pods-another.im.adhoc.xcconfig"; sourceTree = ""; }; + FCADB10208409DD462AC921F /* Pods-Monal.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.beta.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.beta.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1D60588F0D05DD3D006BFB54 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8418B5672C87E0ED006FAF60 /* ExyteChat in Frameworks */, + 261E542523A0A1D300394F59 /* monalxmpp.framework in Frameworks */, + 84F194D12C15197200F0A994 /* FrameUp in Frameworks */, + C176F1EC2AF11C31002034E5 /* UserNotifications.framework in Frameworks */, + C1F5C7AF2777638B0001F295 /* OrderedCollections in Frameworks */, + 841898AA2957712000FEC77D /* ViewExtractor in Frameworks */, + 7D40218FEAB3BA882811A682 /* Pods_Monal.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 260773BD232FC4E800BFD50F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 54D2307E24CB0F4600638D65 /* monalxmpp.framework in Frameworks */, + 08CAF17FA202CF3CB760D93C /* Pods_NotificationService.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 26AA700E2146BBB800598605 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 26CC57EB23A08F4400ABB92A /* monalxmpp.framework in Frameworks */, + F9C277F46F5157194744C491 /* Pods_shareSheet.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 26CC578F23A0867400ABB92A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 849ADF3F2BACF0360009BCD7 /* CocoaLumberjack in Frameworks */, + 7E995F302CEAC9F6005B30EE /* Pods_monalxmpp.framework in Frameworks */, + 849ADF412BACF0360009BCD7 /* CocoaLumberjackSwift in Frameworks */, + 84E231F32C16A9CE00735FB7 /* SVGView in Frameworks */, + 849ADF432BACF0360009BCD7 /* CocoaLumberjackSwiftLogBackend in Frameworks */, + 8414AE002A7ABC4300EFFCCC /* LibMonalRustSwiftBridge in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7E995F032CEAC4B8005B30EE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E995F2B2CEAC9A0005B30EE /* monalxmpp.framework in Frameworks */, + 6E9488F6997650B805476F25 /* Pods_another_im.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C1049183261301530054AC9E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C104918B261301530054AC9E /* monalxmpp.framework in Frameworks */, + 43A92B80C8CD5E04074B5A3E /* Pods_MonalXMPPUnitTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C1850EB225F38A2D003D506A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D7E74AF213445E39318BC648 /* Pods_MonalUITests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 080E96DDFE201D6D7F000001 /* Shared */ = { + isa = PBXGroup; + children = ( + 267B671E226A2208003DB850 /* Crypto */, + 26C3456B1C68F9D9007F4BEC /* HTTP */, + 260C51CE177F08CF0039634B /* XMPP */, + 26352EA5177D21FC00E2C8FF /* Managers */, + 26F7BB3A1115F8A300712672 /* tools */, + 2601D9A70FBF255D004DB939 /* DataLayer.m */, + 2601D9A80FBF255D004DB939 /* DataLayer.h */, + 262D9EAB17924532009292B4 /* MLConstants.h */, + 26EC411117BDE39E0031304D /* MLImageManager.h */, + 26EC411217BDE39E0031304D /* MLImageManager.m */, + 263AFDFA209B3B35007F9CEE /* MLSignalStore.h */, + 263AFDFB209B3B35007F9CEE /* MLSignalStore.m */, + C16D18332792A4AF00F869A0 /* DataLayerMigrations.h */, + C16D18342792A4AF00F869A0 /* DataLayerMigrations.m */, + 84FC37542897521400634E3E /* snprintf.m */, + ); + name = Shared; + path = Classes; + sourceTree = ""; + }; + 19C28FACFE9D520D11CA2CBB /* Products */ = { + isa = PBXGroup; + children = ( + 26080210110ABA4E005E194D /* Monal.app */, + 26AA70112146BBB800598605 /* shareSheet.appex */, + 260773C0232FC4E800BFD50F /* NotificationService.appex */, + 26CC579223A0867400ABB92A /* monalxmpp.framework */, + C1850EB525F38A2D003D506A /* .xctest */, + C1049186261301530054AC9E /* MonalXMPPUnitTests.xctest */, + 7E995F062CEAC4B8005B30EE /* another.im.app */, + ); + name = Products; + sourceTree = ""; + }; + 260773C1232FC4E800BFD50F /* NotificationService */ = { + isa = PBXGroup; + children = ( + C1567E3728255D02006E9637 /* NotificationService.ios.entitlements */, + C1567E3828255D02006E9637 /* NotificationService.macos.entitlements */, + 260773C2232FC4E800BFD50F /* NotificationService.h */, + 260773C3232FC4E800BFD50F /* NotificationService.m */, + 260773C5232FC4E800BFD50F /* Info.plist */, + ); + path = NotificationService; + sourceTree = ""; + }; + 260C51CE177F08CF0039634B /* XMPP */ = { + isa = PBXGroup; + children = ( + 54E594BA2523C34900E4172B /* MLPubSub.h */, + 54E594BC2523C34A00E4172B /* MLPubSub.m */, + 2661102E238F17D00030A4EE /* Models */, + 2633CB40231CB7F5006D0277 /* Processors */, + 26CF3EAF1780DF8B002B7085 /* Parsers */, + 26CF3EAE1780DF86002B7085 /* Nodes */, + 260C51CF177F08F50039634B /* xmpp.h */, + 260C51D0177F08F50039634B /* xmpp.m */, + 26C4B22221B6B91300610282 /* MLDNSLookup.h */, + 26C4B22321B6B91300610282 /* MLDNSLookup.m */, + 2661100B238F08820030A4EE /* MLXMPPServer.h */, + 2661100C238F08820030A4EE /* MLXMPPServer.m */, + 26611010238F08980030A4EE /* MLXMPPIdentity.h */, + 26611011238F08980030A4EE /* MLXMPPIdentity.m */, + 26611015238F08AC0030A4EE /* MLXMPPConnection.h */, + 26611016238F08AC0030A4EE /* MLXMPPConnection.m */, + 3872091F251EDE07001837EB /* MLXEPSlashMeHandler.h */, + 38720921251EDE07001837EB /* MLXEPSlashMeHandler.m */, + 54507CE4255D8C14007092F4 /* MLFiletransfer.m */, + 54507CE7255D8C47007092F4 /* MLFiletransfer.h */, + ); + name = XMPP; + sourceTree = ""; + }; + 262797AC178A573F00B85D94 /* Tableviewcells */ = { + isa = PBXGroup; + children = ( + 26D89F041A890672009B147C /* MLSwitchCell.h */, + 264A7E741AA7263600E860E3 /* MLSwitchCell.xib */, + 26D89F051A890672009B147C /* MLSwitchCell.m */, + 262797AD178A577300B85D94 /* MLContactCell.h */, + 262797AE178A577300B85D94 /* MLContactCell.m */, + 26F9794C1ACAC73A0008E005 /* MLContactCell.xib */, + 26AAE283179F7B0200271345 /* MLSettingCell.h */, + 26AAE284179F7B0200271345 /* MLSettingCell.m */, + 262E518F1AD8CAC600788351 /* MLButtonCell.h */, + 262E51901AD8CAC600788351 /* MLButtonCell.m */, + 262E51911AD8CAC600788351 /* MLButtonCell.xib */, + 262E51941AD8CB7200788351 /* MLTextInputCell.h */, + 262E51951AD8CB7200788351 /* MLTextInputCell.m */, + 262E51961AD8CB7200788351 /* MLTextInputCell.xib */, + ); + name = Tableviewcells; + sourceTree = ""; + }; + 2633CB40231CB7F5006D0277 /* Processors */ = { + isa = PBXGroup; + children = ( + C158D41225A0AC630005AA40 /* MLMucProcessor.m */, + C158D3FE25A0AB810005AA40 /* MLMucProcessor.h */, + 2633CB41231CB816006D0277 /* MLMessageProcessor.h */, + 2633CB42231CB816006D0277 /* MLMessageProcessor.m */, + 2661101A238F10360030A4EE /* MLIQProcessor.h */, + 2661101B238F10360030A4EE /* MLIQProcessor.m */, + 2661101F238F104B0030A4EE /* MLPresenceProcessor.h */, + 26611020238F104B0030A4EE /* MLPresenceProcessor.m */, + 541E4CC3254D369100FD7B28 /* MLPubSubProcessor.h */, + 541E4CC6254D370100FD7B28 /* MLPubSubProcessor.m */, + ); + name = Processors; + sourceTree = ""; + }; + 26352EA5177D21FC00E2C8FF /* Managers */ = { + isa = PBXGroup; + children = ( + C15D504824C727CC002F75BB /* MLLogFileManager.h */, + C15D504A24C727CC002F75BB /* MLLogFileManager.m */, + 26352EA6177D222600E2C8FF /* MLXMPPManager.h */, + 26352EA7177D222600E2C8FF /* MLXMPPManager.m */, + 389E298B25E901CA009A5268 /* MLAudioRecoderManager.h */, + 389E298925E901CA009A5268 /* MLAudioRecoderManager.m */, + ); + name = Managers; + sourceTree = ""; + }; + 2644D48F1FF004B800F46AB5 /* ChatViews */ = { + isa = PBXGroup; + children = ( + 840E23C928ADA56900A7FAC9 /* MLUploadQueueCell.h */, + 840E23C828ADA56900A7FAC9 /* MLUploadQueueCell.m */, + 2696EED11791245A00BC54B8 /* chatViewController.m */, + 2696EED01791245A00BC54B8 /* chatViewController.h */, + 26FE3BC91C61A6C3003CC230 /* MLResizingTextView.h */, + 26FE3BCA1C61A6C3003CC230 /* MLResizingTextView.m */, + 268DD58417C4541000C673A9 /* MLChatCell.h */, + 268DD58517C4541000C673A9 /* MLChatCell.m */, + 2644D4901FF0064C00F46AB5 /* MLChatImageCell.h */, + 2644D4911FF0064C00F46AB5 /* MLChatImageCell.m */, + 2644D4931FF046E800F46AB5 /* MLBaseCell.h */, + 2644D4941FF046E800F46AB5 /* MLBaseCell.m */, + 263DFAC12187D0E00038E716 /* MLLinkCell.h */, + 263DFAC22187D0E00038E716 /* MLLinkCell.m */, + 26D7C05C23D6AFD800CA123C /* MLChatInputContainer.h */, + 26D7C05D23D6AFD800CA123C /* MLChatInputContainer.m */, + C1414E9B24312F0100948788 /* MLChatMapsCell.h */, + C1414E9C24312F0100948788 /* MLChatMapsCell.m */, + C1A80DA224D9552400B99E01 /* MLChatViewHelper.h */, + C1A80DA324D9552400B99E01 /* MLChatViewHelper.m */, + C1943A4A25309A9D0036172F /* MLReloadCell.h */, + C1943A4B25309A9D0036172F /* MLReloadCell.m */, + E8DED05F25388BE8003167FF /* MLSearchViewController.h */, + E8DED06125388BE8003167FF /* MLSearchViewController.m */, + E89DD31E25C6626300925F62 /* MLFileTransferDataCell.h */, + E89DD32025C6626300925F62 /* MLFileTransferDataCell.m */, + E89DD31C25C6626300925F62 /* MLFileTransferFileViewController.h */, + E89DD32225C6626300925F62 /* MLFileTransferFileViewController.m */, + E89DD32325C6626400925F62 /* MLFileTransferTextCell.h */, + E89DD32425C6626400925F62 /* MLFileTransferTextCell.m */, + E89DD31F25C6626300925F62 /* MLFileTransferVideoCell.h */, + E89DD32125C6626300925F62 /* MLFileTransferVideoCell.m */, + ); + name = ChatViews; + sourceTree = ""; + }; + 2644D4961FF29E1900F46AB5 /* Settings */ = { + isa = PBXGroup; + children = ( + 2644D4971FF29E5600F46AB5 /* MLSettingsTableViewController.h */, + 2644D4981FF29E5600F46AB5 /* MLSettingsTableViewController.m */, + 26B0CA8721AE2E3C0080B133 /* MLSoundsTableViewController.h */, + 26B0CA8821AE2E3C0080B133 /* MLSoundsTableViewController.m */, + 3D06A514281FFCC000DDAE90 /* NotificationDebugging.swift */, + E8CF9CBF26249640001A1952 /* MLSettingsAboutViewController.h */, + E8CF9CC026249640001A1952 /* MLSettingsAboutViewController.m */, + 8441EFF82921B53500E851E9 /* BackgroundSettings.swift */, + 20ED55842BADDA5C0005783E /* GeneralSettings.swift */, + ); + name = Settings; + sourceTree = ""; + }; + 2661102E238F17D00030A4EE /* Models */ = { + isa = PBXGroup; + children = ( + 26611029238F17CA0030A4EE /* MLContact.h */, + 2661102A238F17CB0030A4EE /* MLContact.m */, + 26611024238F17B20030A4EE /* MLMessage.h */, + 26611025238F17B20030A4EE /* MLMessage.m */, + C1F5C7A72775DA000001F295 /* MLContactSoftwareVersionInfo.h */, + C1F5C7A82775DA000001F295 /* MLContactSoftwareVersionInfo.m */, + ); + name = Models; + sourceTree = ""; + }; + 2665C1191C4AA22700CC9A04 /* Accounts */ = { + isa = PBXGroup; + children = ( + 261A6289176C159000059090 /* AccountListController.h */, + 261A628A176C159000059090 /* AccountListController.m */, + 2636C43D177BD58C001CA71F /* XMPPEdit.h */, + 2636C43E177BD58C001CA71F /* XMPPEdit.m */, + 262AEFE620AE756800498F82 /* MLMAMPrefTableViewController.h */, + 262AEFE720AE756800498F82 /* MLMAMPrefTableViewController.m */, + 26AAAAD82295742400200433 /* MLPasswordChangeTableViewController.h */, + 26AAAAD92295742400200433 /* MLPasswordChangeTableViewController.m */, + D0FA79B02C7E5C7400216D2A /* ServerDetails.swift */, + D02192F22C89BB3800202A59 /* BlockedUsers.swift */, + ); + name = Accounts; + sourceTree = ""; + }; + 26715E5E17650AF900684F3D /* View Controllers */ = { + isa = PBXGroup; + children = ( + 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */, + 849248482AD4CEC400986C1A /* ZoomableContainer.swift */, + 845D636A2AD4AEDA0066EFFB /* MediaViewer.swift */, + 841B6F16297AFB340074F9B7 /* Calls */, + 3D65B790272350F0005A30F4 /* SwiftuiHelpers.swift */, + C15489B825680BBE00BBA2F0 /* MLQRCodeScanner.swift */, + 3D7D352228626CB80042C5E5 /* LoadingOverlay.swift */, + 26F396CE2196830E00EF5720 /* OnBoard */, + 26DB52121777EA5100B50353 /* Tab views */, + 26158AF01FFA6E4500E53BDC /* MLWebViewController.h */, + 26158AF11FFA6E4500E53BDC /* MLWebViewController.m */, + 84FC375828981A5600634E3E /* PasswordMigration.swift */, + 34E58B4A2C68E7BC009A1634 /* ContactsView.swift */, + 3D631822294BAB1D00026BE7 /* ContactPicker.swift */, + 3D27D955290B0BB60014748B /* AddContactMenu.swift */, + 3D88BB76295BB6DC00FB30BA /* CreateGroupMenu.swift */, + 3D27D957290B0BC80014748B /* ContactRequestsMenu.swift */, + 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */, + 841898AB2957DBAC00FEC77D /* RichAlert.swift */, + C114D13C2B15B903000FB99F /* ContactEntry.swift */, + 952EBC7F2BAF72F300183DBF /* DebugView.swift */, + 34BC08112C5E9BE30099FB85 /* ContentUnavailableShimView.swift */, + ); + name = "View Controllers"; + path = Classes; + sourceTree = ""; + }; + 267B671E226A2208003DB850 /* Crypto */ = { + isa = PBXGroup; + children = ( + C1C839DA24F15DF800BBCF17 /* MLOMEMO.h */, + C1C839DC24F15DF800BBCF17 /* MLOMEMO.m */, + 267B671A226A200D003DB850 /* AESGcm.h */, + 267B671B226A200D003DB850 /* AESGcm.m */, + 267B671F226A222E003DB850 /* MLEncryptedPayload.h */, + 267B6720226A222E003DB850 /* MLEncryptedPayload.m */, + C1850E6625F37EC0003D506A /* Monal Tests-Bridging-Header.h */, + C10490E22612F3D00054AC9E /* MLCrypto.swift */, + C10490EA2612F3E00054AC9E /* EncryptedPayload.swift */, + 848904A8289C82C30097E19C /* SCRAM.m */, + 848904AA289C82DB0097E19C /* SCRAM.h */, + 8420EA9A2915E4FE0038FF40 /* OmemoState.h */, + 8420EA9C2915E5100038FF40 /* OmemoState.m */, + ); + name = Crypto; + sourceTree = ""; + }; + 268C1E471FDACDD300BD6921 /* Contact Details */ = { + isa = PBXGroup; + children = ( + C1F5C7AB2777621B0001F295 /* ContactResources.swift */, + 3D85E586282AE523006F5B3A /* OmemoQrCodeView.swift */, + 3DC5035B2822F5220064C8A7 /* OmemoKeysView.swift */, + 3D65B78C27234B74005A30F4 /* ContactDetails.swift */, + 3D5A91412842B4AE008CE57E /* MemberList.swift */, + C18967C62B81F61B0073C7C5 /* ChannelMemberList.swift */, + C1E8A7F62B8E47C300760220 /* EditGroupSubject.swift */, + 20D8C65D2C3C37FE00E6BDA2 /* MediaGallery.swift */, + ); + name = "Contact Details"; + sourceTree = ""; + }; + 26AA70122146BBB900598605 /* shareSheet-iOS */ = { + isa = PBXGroup; + children = ( + C152D674279D382200113E47 /* localization */, + 26AA70132146BBB900598605 /* ShareViewController.h */, + 26AA70142146BBB900598605 /* ShareViewController.m */, + 26AA70192146BBB900598605 /* Info.plist */, + 263DFABD2183A59B0038E716 /* MLSelectionController.h */, + 263DFABE2183A59B0038E716 /* MLSelectionController.m */, + ); + path = "shareSheet-iOS"; + sourceTree = ""; + }; + 26B2A4B41B73040000272E63 /* Monal-iOS */ = { + isa = PBXGroup; + children = ( + 845836B92C49F36300B11EC5 /* Quicksy Launch Screen.storyboard */, + 841EE42E2A426F0900D3AF14 /* tools */, + 84D31CDA28653AA9006D7926 /* WebRTC */, + C1E4654424EE515200CA5AAF /* localization */, + 2649CE521C51B84C00CD1E80 /* Launch Screen.storyboard */, + 26B2A4BA1B73061400272E63 /* Images.xcassets */, + 26B2A4B71B7304DD00272E63 /* Notifications */, + 1D3623240D0F684500981E51 /* MonalAppDelegate.h */, + 1D3623250D0F684500981E51 /* MonalAppDelegate.m */, + 26715E5E17650AF900684F3D /* View Controllers */, + 8D1107310486CEB800E47090 /* Monal-Info.plist */, + ); + name = "Monal-iOS"; + sourceTree = ""; + }; + 26B2A4B71B7304DD00272E63 /* Notifications */ = { + isa = PBXGroup; + children = ( + 26EE8B1D179B67FA006781F3 /* MLNotificationManager.h */, + 26EE8B1E179B67FA006781F3 /* MLNotificationManager.m */, + ); + name = Notifications; + sourceTree = ""; + }; + 26C3456B1C68F9D9007F4BEC /* HTTP */ = { + isa = PBXGroup; + children = ( + 26C3456C1C68F9F0007F4BEC /* MLHTTPRequest.h */, + 26C3456D1C68F9F0007F4BEC /* MLHTTPRequest.m */, + ); + name = HTTP; + sourceTree = ""; + }; + 26CC579323A0867400ABB92A /* monalxmpp */ = { + isa = PBXGroup; + children = ( + 26CC579423A0867400ABB92A /* monalxmpp.h */, + 26CC579523A0867400ABB92A /* Info.plist */, + ); + path = monalxmpp; + sourceTree = ""; + }; + 26CF3EAE1780DF86002B7085 /* Nodes */ = { + isa = PBXGroup; + children = ( + 260C51D2177F44AD0039634B /* MLXMLNode.h */, + 260C51D3177F44AD0039634B /* MLXMLNode.m */, + 26CF3EA81780D6B7002B7085 /* XMPPIQ.h */, + 26CF3EA91780D6B7002B7085 /* XMPPIQ.m */, + 264E34591787BAB100BC7BD0 /* XMPPPresence.h */, + 264E345A1787BAB100BC7BD0 /* XMPPPresence.m */, + 262D9EA817921E82009292B4 /* XMPPMessage.h */, + 262D9EA917921E82009292B4 /* XMPPMessage.m */, + 54179CBF251CBAF9008F398E /* XMPPStanza.m */, + 54179CC2251CBB2B008F398E /* XMPPStanza.h */, + 544656BA2534910D006B2953 /* XMPPDataForm.m */, + 544656BC25349133006B2953 /* XMPPDataForm.h */, + ); + name = Nodes; + sourceTree = ""; + }; + 26CF3EAF1780DF8B002B7085 /* Parsers */ = { + isa = PBXGroup; + children = ( + C1613B572520723C0062C0C2 /* MLBasePaser.h */, + C1613B592520723C0062C0C2 /* MLBasePaser.m */, + ); + name = Parsers; + sourceTree = ""; + }; + 26DB52121777EA5100B50353 /* Tab views */ = { + isa = PBXGroup; + children = ( + 8418B5582C7EBD39006FAF60 /* ActiveChatsViewController.h */, + 2644D4961FF29E1900F46AB5 /* Settings */, + 2644D48F1FF004B800F46AB5 /* ChatViews */, + 268C1E471FDACDD300BD6921 /* Contact Details */, + 2665C1191C4AA22700CC9A04 /* Accounts */, + 262797AC178A573F00B85D94 /* Tableviewcells */, + 261A6284176C156500059090 /* ActiveChatsViewController.m */, + 2609B5271FD5B26800F09FA1 /* MLSplitViewDelegate.h */, + 2609B5281FD5B26800F09FA1 /* MLSplitViewDelegate.m */, + 2664D28323F2312400CD4085 /* MLAccountPickerViewController.h */, + 2664D28423F2312400CD4085 /* MLAccountPickerViewController.m */, + ); + name = "Tab views"; + sourceTree = ""; + }; + 26F396CE2196830E00EF5720 /* OnBoard */ = { + isa = PBXGroup; + children = ( + 54F0B81B282316F5003664BD /* RegisterAccount.swift */, + 54F0B81828231690003664BD /* WelcomeLogIn.swift */, + C12436122434AB5D00B8F074 /* MLAttributedLabel.h */, + C12436132434AB5D00B8F074 /* MLAttributedLabel.m */, + 20D3611B2C10E12500E46587 /* BoardingCards.swift */, + 84BBAEC92C42D272009492E2 /* Quicksy_RegisterAccount.swift */, + ); + name = OnBoard; + sourceTree = ""; + }; + 26F7BB3A1115F8A300712672 /* tools */ = { + isa = PBXGroup; + children = ( + 8418B5612C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.h */, + 8418B5622C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.m */, + 8418B55B2C7EC6B7006FAF60 /* Quicksy_Country.h */, + 8418B55C2C7EC6B7006FAF60 /* Quicksy_Country.m */, + 84FC37562897523500634E3E /* metamacros.h */, + 54391BB9273499E80050DAFB /* UIColor+Extension.h */, + 54391BBA273499E80050DAFB /* UIColor+Extension.m */, + 542CF3FD2763314E002C3710 /* hsluv.c */, + 542CF3FE2763314F002C3710 /* hsluv.h */, + 541B6AB3262BC9040038B936 /* MLStream.h */, + 541B6AB2262BC9040038B936 /* MLStream.m */, + 54A22D2C26185E7E00B56EAD /* MLNotificationQueue.h */, + 5455BC3224EB776E0024D80F /* MLUDPLogger.h */, + 5455BC3024EB776B0024D80F /* MLUDPLogger.m */, + 540BD0D324D8D1FF0087A743 /* IPC.m */, + 540BD0D124D8D1F20087A743 /* IPC.h */, + 541E4CBB254AA08600FD7B28 /* MLSQLite.h */, + 540A3FAC24D674BD0008965D /* MLSQLite.m */, + C18E7579245E8AE900AE8FB7 /* MLPipe.m */, + C18E7578245E8AE900AE8FB7 /* MLPipe.h */, + C1AAC3E224B5EF4100BB15D6 /* HelperTools.h */, + C1AAC3E324B5EF4100BB15D6 /* HelperTools.m */, + 540E139F24CDCDB30038FDA0 /* MLProcessLock.m */, + 540E13A124CDCE3B0038FDA0 /* MLProcessLock.h */, + 541E4CBD254AA0B600FD7B28 /* MLHandler.h */, + 541E4CBF254AA0E700FD7B28 /* MLHandler.m */, + C10490482612ED2F0054AC9E /* MLEmoji.swift */, + 54A22D2426185C2900B56EAD /* MLNotificationQueue.m */, + 84C1CD4F2A8C764D007076ED /* SwiftHelpers.swift */, + 84C1CD512A8F617F007076ED /* MLStreamRedirect.m */, + 84C1CD532A8F6196007076ED /* MLStreamRedirect.h */, + 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */, + 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */, + ); + name = tools; + sourceTree = ""; + }; + 29B97314FDCFA39411CA2CEA /* CustomTemplate */ = { + isa = PBXGroup; + children = ( + 8414ADF92A7ABAC900EFFCCC /* Packages */, + C1F0AD15288BCE6F00BB0182 /* Monal.Alpha.ios.entitlements */, + C1567E3628255C64006E9637 /* Monal.ios.entitlements */, + C1567E3528255C64006E9637 /* Monal.macos.entitlements */, + 26AA70222146E2B900598605 /* shareSheet.entitlements */, + 29B97315FDCFA39411CA2CEA /* Root Sources */, + 080E96DDFE201D6D7F000001 /* Shared */, + 29B97317FDCFA39411CA2CEA /* Resources */, + 26B2A4B41B73040000272E63 /* Monal-iOS */, + 26AA70122146BBB900598605 /* shareSheet-iOS */, + 260773C1232FC4E800BFD50F /* NotificationService */, + 26CC579323A0867400ABB92A /* monalxmpp */, + C1850EB625F38A2D003D506A /* MonalUITests */, + C1049187261301530054AC9E /* MonalXMPPUnitTests */, + 7E995F232CEAC5D2005B30EE /* another.im */, + 19C28FACFE9D520D11CA2CBB /* Products */, + 82B5509417C9F86AAC2B4FA1 /* Pods */, + D91581D0612C23AB5B3F867C /* Frameworks */, + ); + name = CustomTemplate; + sourceTree = ""; + }; + 29B97315FDCFA39411CA2CEA /* Root Sources */ = { + isa = PBXGroup; + children = ( + 32CA4F630368D1EE00C91783 /* MonalSourceCodePrefix.pch */, + 29B97316FDCFA39411CA2CEA /* main.m */, + ); + name = "Root Sources"; + sourceTree = ""; + }; + 29B97317FDCFA39411CA2CEA /* Resources */ = { + isa = PBXGroup; + children = ( + 848C73DF2BDF2014007035C9 /* PrivacyInfo.xcprivacy */, + 842790842A32D16C005C18CC /* CallSounds */, + 26B0CA8A21AE410E0080B133 /* AlertSounds */, + 26158AF31FFA8A6A00E53BDC /* opensource.html */, + 26470F511835C4080069E3E0 /* Media.xcassets */, + 2601D9CA0FBF25EF004DB939 /* sworim.sqlite */, + ); + name = Resources; + sourceTree = ""; + }; + 7E995F232CEAC5D2005B30EE /* another.im */ = { + isa = PBXGroup; + children = ( + 7E995F202CEAC5D2005B30EE /* AnotherIMApp.swift */, + 7E995F212CEAC5D2005B30EE /* Assets.xcassets */, + 7E995F222CEAC5D2005B30EE /* ContentView.swift */, + ); + path = another.im; + sourceTree = ""; + }; + 82B5509417C9F86AAC2B4FA1 /* Pods */ = { + isa = PBXGroup; + children = ( + 7FA9582E4CC566FE5466C557 /* Pods-Monal.debug.xcconfig */, + AD0E234056402EE91A36D628 /* Pods-Monal.adhoc.xcconfig */, + 1D46F251C198E3D8FA55692F /* Pods-Monal.appstore.xcconfig */, + 86CF04FDE084C646CA14B774 /* Pods-Monal Tests.debug.xcconfig */, + 6142E73A9912F6E327A8CD14 /* Pods-Monal Tests.adhoc.xcconfig */, + 6A6D58F695CDFAF204F3B3EB /* Pods-Monal Tests.appstore.xcconfig */, + 0D0362F6B26B9407BE313F36 /* Pods-NotificaionService.debug.xcconfig */, + 4A614910EEF29D66DD4B37E3 /* Pods-NotificaionService.adhoc.xcconfig */, + D310A8387B2EB10761312F77 /* Pods-NotificaionService.appstore.xcconfig */, + B55DCA2ABBDB6E635D46D69A /* Pods-monalxmpp.debug.xcconfig */, + E06DB4446BAE2F9C0192D055 /* Pods-monalxmpp.adhoc.xcconfig */, + AAFC73E987D41BA8D91E9F95 /* Pods-monalxmpp.appstore.xcconfig */, + 21E99538324C14220843F325 /* Pods-shareSheet.debug.xcconfig */, + 0EC0A5305E72C0F7F9E1CDEF /* Pods-shareSheet.adhoc.xcconfig */, + 061EF1BEDEE7A71FDF9AB402 /* Pods-shareSheet.appstore.xcconfig */, + F7506FDE7A78EB0CAB14FF60 /* Pods-MonalUITests.debug.xcconfig */, + 39B989B9775C0725A810D271 /* Pods-MonalUITests.adhoc.xcconfig */, + D8D2595B2BE453296E59F1AF /* Pods-MonalUITests.appstore.xcconfig */, + 3EB7A7084FA9A8F68A3D251C /* Pods-MonalXMPPUnitTests.debug.xcconfig */, + 9705AFFB59AF72A9B79C1D7B /* Pods-MonalXMPPUnitTests.adhoc.xcconfig */, + 2369191B3FCB2E941169A093 /* Pods-MonalXMPPUnitTests.appstore.xcconfig */, + FCADB10208409DD462AC921F /* Pods-Monal.beta.xcconfig */, + 7D6715099247A9CCC180EE30 /* Pods-MonalUITests.beta.xcconfig */, + 4862C3A0242FB4F709B8F3FF /* Pods-MonalXMPPUnitTests.beta.xcconfig */, + A4C686567AC126CDDFB1BE44 /* Pods-NotificaionService.beta.xcconfig */, + DC5DA2C9782510B3433FD50D /* Pods-monalxmpp.beta.xcconfig */, + 6BCB9FB4EBEA3735D24A44DF /* Pods-shareSheet.beta.xcconfig */, + 9162E3E9FCE2280BF75F5102 /* Pods-Monal.alpha-ios.xcconfig */, + 66387B079AB74C687D46FD0A /* Pods-Monal.alpha-catalyst.xcconfig */, + 222F09C97CFF93A2CF1007F3 /* Pods-MonalUITests.alpha-ios.xcconfig */, + A2ED40D3515305509E3E166C /* Pods-MonalUITests.alpha-catalyst.xcconfig */, + 140EC3B65541FD42B8A9A3C4 /* Pods-MonalXMPPUnitTests.alpha-ios.xcconfig */, + 681C751BDE486C4E3A43CF72 /* Pods-MonalXMPPUnitTests.alpha-catalyst.xcconfig */, + 2E5021A8D40FCC591D952104 /* Pods-NotificaionService.alpha-ios.xcconfig */, + 59F4A459FBC6040A0F8CCAF3 /* Pods-NotificaionService.alpha-catalyst.xcconfig */, + 72CBE47E31AF93F7357B1202 /* Pods-monalxmpp.alpha-ios.xcconfig */, + F04FE24284F1563D80523B52 /* Pods-monalxmpp.alpha-catalyst.xcconfig */, + 43FFAD161EF5A0B1CB149814 /* Pods-shareSheet.alpha-ios.xcconfig */, + 1059D059522057BFA830995B /* Pods-shareSheet.alpha-catalyst.xcconfig */, + BFA9EFD7A8064201C81F52CF /* Pods-Monal.alpha.xcconfig */, + 5BACDACCFE405FE0C903C897 /* Pods-MonalUITests.alpha.xcconfig */, + 4E53581DEF864B229A09FA61 /* Pods-MonalXMPPUnitTests.alpha.xcconfig */, + 797F93A1B3C6B4735F2ABE7D /* Pods-NotificationService.debug.xcconfig */, + B8155E63F8DE80FF36D0B3B7 /* Pods-NotificationService.adhoc.xcconfig */, + 671D139EE64DB6AD9E1D8108 /* Pods-NotificationService.alpha.xcconfig */, + 79A6AA4819B69B5FFFA28236 /* Pods-NotificationService.appstore.xcconfig */, + 4049F81F60EA5B7A57A4E9C6 /* Pods-NotificationService.beta.xcconfig */, + 39DB4C9159DA578D1A34990D /* Pods-monalxmpp.alpha.xcconfig */, + 6015D382ABCE0D788029D7A3 /* Pods-shareSheet.alpha.xcconfig */, + 213F5BFD4599EC9317B99E97 /* Pods-Monal.appstore-quicksy.xcconfig */, + 26ABE9FB494A9E7F3044C695 /* Pods-MonalUITests.appstore-quicksy.xcconfig */, + B58835E4BBDCCB6BE1E8F0AE /* Pods-MonalXMPPUnitTests.appstore-quicksy.xcconfig */, + AA697C1F9B9637B86665DFF1 /* Pods-NotificationService.appstore-quicksy.xcconfig */, + 9899D670570190DCBE9EEDDB /* Pods-monalxmpp.appstore-quicksy.xcconfig */, + 9760CF4718351300C4256921 /* Pods-shareSheet.appstore-quicksy.xcconfig */, + 1936866375CABF471D3CE238 /* Pods-another.im.debug.xcconfig */, + F8ACC07B95446BB8052933BF /* Pods-another.im.adhoc.xcconfig */, + BC9E05245CF07072A35AE126 /* Pods-another.im.alpha.xcconfig */, + F2082C5D72E8D7D49B31FBEE /* Pods-another.im.appstore.xcconfig */, + 7D281334DB441077E42E3E89 /* Pods-another.im.appstore-quicksy.xcconfig */, + 3D8AAFBF5B865907983E9F59 /* Pods-another.im.beta.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 8414ADF92A7ABAC900EFFCCC /* Packages */ = { + isa = PBXGroup; + children = ( + 8414ADFA2A7ABAC900EFFCCC /* LibMonalRustSwiftBridge */, + ); + name = Packages; + sourceTree = ""; + }; + 841B6F16297AFB340074F9B7 /* Calls */ = { + isa = PBXGroup; + children = ( + 841B6F1B297B3CFC0074F9B7 /* AVCallUI.swift */, + 841B6F19297B18720074F9B7 /* AccountPicker.swift */, + ); + name = Calls; + sourceTree = ""; + }; + 841EE42E2A426F0900D3AF14 /* tools */ = { + isa = PBXGroup; + children = ( + 843AD3AA2AA55CE20036844D /* MLOgHtmlParser.swift */, + 841EE42F2A426F2300D3AF14 /* MLCrashReporter.m */, + 841EE4312A426F3D00D3AF14 /* MLCrashReporter.h */, + ); + name = tools; + sourceTree = ""; + }; + 84D31CDA28653AA9006D7926 /* WebRTC */ = { + isa = PBXGroup; + children = ( + 848717F2295ED64500B8D288 /* MLCall.h */, + 848717F1295ED64500B8D288 /* MLCall.m */, + 84D31CE528653B83006D7926 /* WebRTCClient.swift */, + 849A53E3287135B2007E941A /* MLVoIPProcessor.m */, + 849A53E5287135D7007E941A /* MLVoIPProcessor.h */, + ); + path = WebRTC; + sourceTree = ""; + }; + C1049187261301530054AC9E /* MonalXMPPUnitTests */ = { + isa = PBXGroup; + children = ( + C1B940E926144AF500E9D290 /* AESGCMTest.m */, + C1049198261301710054AC9E /* MLCryptoTest.swift */, + C1049188261301530054AC9E /* MonalXMPPUnitTests.swift */, + C104918A261301530054AC9E /* Info.plist */, + C1B940E826144AF400E9D290 /* MonalXMPPUnitTests-Bridging-Header.h */, + ); + path = MonalXMPPUnitTests; + sourceTree = ""; + }; + C152D674279D382200113E47 /* localization */ = { + isa = PBXGroup; + children = ( + 26AA70162146BBB900598605 /* iosShare.storyboard */, + ); + path = localization; + sourceTree = ""; + }; + C1850EB625F38A2D003D506A /* MonalUITests */ = { + isa = PBXGroup; + children = ( + C1850EB725F38A2D003D506A /* MonalUITests.swift */, + C1850EB925F38A2D003D506A /* Info.plist */, + C1850EC525F3C5EB003D506A /* TestHelper.swift */, + ); + path = MonalUITests; + sourceTree = ""; + }; + C1E4654424EE515200CA5AAF /* localization */ = { + isa = PBXGroup; + children = ( + C1E4654624EE517000CA5AAF /* Localizable.strings */, + 26E8462A24EABAED00ECE419 /* Main.storyboard */, + 26E8462424EABAD600ECE419 /* Settings.storyboard */, + ); + path = localization; + sourceTree = ""; + }; + D91581D0612C23AB5B3F867C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7E995F282CEAC672005B30EE /* ASN1Decoder.framework */, + C176F1EB2AF11C31002034E5 /* UserNotifications.framework */, + 8C40963CED187B2F1B4B88F7 /* Pods_Monal.framework */, + 29250DA62DD2322383585B2B /* Pods_MonalUITests.framework */, + 2C59BAF969550DFAC27E5F2B /* Pods_MonalXMPPUnitTests.framework */, + 5B9C86E0A568734587FE9BA2 /* Pods_monalxmpp.framework */, + 8F1488206B764014DD7EC92A /* Pods_shareSheet.framework */, + 2B7A5555D807EE78C95217FD /* Pods_NotificationService.framework */, + F29121F912380F72CCE51747 /* Pods_another_im.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 26CC578D23A0867400ABB92A /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + C16D18352792A4AF00F869A0 /* DataLayerMigrations.h in Headers */, + 26CC579623A0867400ABB92A /* monalxmpp.h in Headers */, + 5427C94C276A6BC4003217D5 /* UIColor+Extension.h in Headers */, + C18E757A245E8AE900AE8FB7 /* MLPipe.h in Headers */, + 54179CC3251CBB2B008F398E /* XMPPStanza.h in Headers */, + 544656BD25349133006B2953 /* XMPPDataForm.h in Headers */, + 54507CE8255D8C47007092F4 /* MLFiletransfer.h in Headers */, + 541B6AB5262BC9040038B936 /* MLStream.h in Headers */, + 540E13A224CDCE3B0038FDA0 /* MLProcessLock.h in Headers */, + 8418B5632C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.h in Headers */, + 541E4CBC254AA08700FD7B28 /* MLSQLite.h in Headers */, + 8420EA9B2915E4FE0038FF40 /* OmemoState.h in Headers */, + C1613B5A2520723D0062C0C2 /* MLBasePaser.h in Headers */, + 542CF4002763314F002C3710 /* hsluv.h in Headers */, + 54E594BD2523C34B00E4172B /* MLPubSub.h in Headers */, + 540BD0D224D8D1F40087A743 /* IPC.h in Headers */, + C1C839DD24F15DF800BBCF17 /* MLOMEMO.h in Headers */, + 26D4389123A5EB6C00242AAA /* MLConstants.h in Headers */, + C158D40025A0AB810005AA40 /* MLMucProcessor.h in Headers */, + 54A22D2D26185E7E00B56EAD /* MLNotificationQueue.h in Headers */, + 541E4CC4254D369200FD7B28 /* MLPubSubProcessor.h in Headers */, + 844921EC2C29F9BE00B99A9C /* MLDelayableTimer.h in Headers */, + 84C1CD542A8F6196007076ED /* MLStreamRedirect.h in Headers */, + 389E298D25E901CA009A5268 /* MLAudioRecoderManager.h in Headers */, + 8418B55D2C7EC6B7006FAF60 /* Quicksy_Country.h in Headers */, + 541E4CBE254AA0B600FD7B28 /* MLHandler.h in Headers */, + C1F5C7A92775DA000001F295 /* MLContactSoftwareVersionInfo.h in Headers */, + 84FC37572897523500634E3E /* metamacros.h in Headers */, + 5455BC3424EB776F0024D80F /* MLUDPLogger.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 1D6058900D05DD3D006BFB54 /* Monal */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1D6058960D05DD3E006BFB54 /* Build configuration list for PBXNativeTarget "Monal" */; + buildPhases = ( + 0105BB524F492059A59FAB32 /* [CP] Check Pods Manifest.lock */, + 1D60588D0D05DD3D006BFB54 /* Resources */, + 1D60588E0D05DD3D006BFB54 /* Sources */, + 1D60588F0D05DD3D006BFB54 /* Frameworks */, + 26AA70212146BBB900598605 /* Embed Foundation Extensions */, + 261E542723A0A1D300394F59 /* Embed Frameworks */, + C19CC5E028E85527007AD33E /* Fix catalyst aps-enviromnent */, + E09BC3B194F91D0DE538C310 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 26CC579823A0867400ABB92A /* PBXTargetDependency */, + 2638008A2374814000144929 /* PBXTargetDependency */, + 26AA701B2146BBB900598605 /* PBXTargetDependency */, + ); + name = Monal; + packageProductDependencies = ( + C1F5C7AE2777638B0001F295 /* OrderedCollections */, + 841898A92957712000FEC77D /* ViewExtractor */, + 84F194D02C15197200F0A994 /* FrameUp */, + 8418B5662C87E0ED006FAF60 /* ExyteChat */, + ); + productName = SworIM; + productReference = 26080210110ABA4E005E194D /* Monal.app */; + productType = "com.apple.product-type.application"; + }; + 260773BF232FC4E800BFD50F /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = 260773CC232FC4E800BFD50F /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + E29F37602913131122522B1F /* [CP] Check Pods Manifest.lock */, + 260773BC232FC4E800BFD50F /* Sources */, + 260773BD232FC4E800BFD50F /* Frameworks */, + 260773BE232FC4E800BFD50F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 26CC57E823A08F3A00ABB92A /* PBXTargetDependency */, + ); + name = NotificationService; + productName = NotificaionService; + productReference = 260773C0232FC4E800BFD50F /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 26AA70102146BBB800598605 /* shareSheet */ = { + isa = PBXNativeTarget; + buildConfigurationList = 26AA70202146BBB900598605 /* Build configuration list for PBXNativeTarget "shareSheet" */; + buildPhases = ( + AEFD0BBC538F58642A554A94 /* [CP] Check Pods Manifest.lock */, + 26AA700D2146BBB800598605 /* Sources */, + 26AA700E2146BBB800598605 /* Frameworks */, + 26AA700F2146BBB800598605 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 26CC57EA23A08F3F00ABB92A /* PBXTargetDependency */, + ); + name = shareSheet; + productName = shareSheet; + productReference = 26AA70112146BBB800598605 /* shareSheet.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 26CC579123A0867400ABB92A /* monalxmpp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 26CC579F23A0867400ABB92A /* Build configuration list for PBXNativeTarget "monalxmpp" */; + buildPhases = ( + D180E765B155419143F176E9 /* [CP] Check Pods Manifest.lock */, + 26CC578D23A0867400ABB92A /* Headers */, + 26CC578E23A0867400ABB92A /* Sources */, + 26CC578F23A0867400ABB92A /* Frameworks */, + 26CC579023A0867400ABB92A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = monalxmpp; + packageProductDependencies = ( + 8414ADFF2A7ABC4300EFFCCC /* LibMonalRustSwiftBridge */, + 849ADF3E2BACF0360009BCD7 /* CocoaLumberjack */, + 849ADF402BACF0360009BCD7 /* CocoaLumberjackSwift */, + 849ADF422BACF0360009BCD7 /* CocoaLumberjackSwiftLogBackend */, + 84E231F22C16A9CE00735FB7 /* SVGView */, + ); + productName = monalxmpp; + productReference = 26CC579223A0867400ABB92A /* monalxmpp.framework */; + productType = "com.apple.product-type.framework"; + }; + 7E995F052CEAC4B8005B30EE /* another.im */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7E995F172CEAC4BA005B30EE /* Build configuration list for PBXNativeTarget "another.im" */; + buildPhases = ( + A9E041FD8051B08903221D89 /* [CP] Check Pods Manifest.lock */, + 7E995F022CEAC4B8005B30EE /* Sources */, + 7E995F032CEAC4B8005B30EE /* Frameworks */, + 7E995F042CEAC4B8005B30EE /* Resources */, + 7E995F2F2CEAC9A0005B30EE /* Embed Frameworks */, + A19DA25BC6A7C3D2C394D914 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 7E995F2E2CEAC9A0005B30EE /* PBXTargetDependency */, + ); + name = another.im; + productName = another.im; + productReference = 7E995F062CEAC4B8005B30EE /* another.im.app */; + productType = "com.apple.product-type.application"; + }; + C1049185261301530054AC9E /* MonalXMPPUnitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C104918E261301530054AC9E /* Build configuration list for PBXNativeTarget "MonalXMPPUnitTests" */; + buildPhases = ( + EF65EEAA25C8D7FEE6305039 /* [CP] Check Pods Manifest.lock */, + C1049182261301530054AC9E /* Sources */, + C1049183261301530054AC9E /* Frameworks */, + C1049184261301530054AC9E /* Resources */, + B79D5724FB4B51022BCFD6E9 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + C104918D261301530054AC9E /* PBXTargetDependency */, + ); + name = MonalXMPPUnitTests; + productName = MonalXMPPUnitTests; + productReference = C1049186261301530054AC9E /* MonalXMPPUnitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + C1850EB425F38A2D003D506A /* MonalUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C1850EBC25F38A2D003D506A /* Build configuration list for PBXNativeTarget "MonalUITests" */; + buildPhases = ( + A0AAAD1CC9A02DA53A59DF82 /* [CP] Check Pods Manifest.lock */, + C1850EB125F38A2D003D506A /* Sources */, + C1850EB225F38A2D003D506A /* Frameworks */, + C1850EB325F38A2D003D506A /* Resources */, + 8FC0217E357D67979CAFB523 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + C1850EBB25F38A2D003D506A /* PBXTargetDependency */, + ); + name = MonalUITests; + productName = MonalUITests; + productReference = C1850EB525F38A2D003D506A /* .xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 29B97313FDCFA39411CA2CEA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + CLASSPREFIX = ML; + DefaultBuildSystemTypeForWorkspace = Original; + LastSwiftUpdateCheck = 1610; + LastUpgradeCheck = 1400; + ORGANIZATIONNAME = "monal-im.org"; + TargetAttributes = { + 1D6058900D05DD3D006BFB54 = { + LastSwiftMigration = 1220; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.DataProtection = { + enabled = 1; + }; + com.apple.Keychain = { + enabled = 1; + }; + com.apple.Push = { + enabled = 1; + }; + com.apple.SafariKeychain = { + enabled = 0; + }; + }; + }; + 260773BF232FC4E800BFD50F = { + CreatedOnToolsVersion = 11.0; + }; + 26AA70102146BBB800598605 = { + CreatedOnToolsVersion = 9.4.1; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + }; + }; + 26CC579123A0867400ABB92A = { + CreatedOnToolsVersion = 11.2.1; + LastSwiftMigration = 1240; + ProvisioningStyle = Manual; + }; + 7E995F052CEAC4B8005B30EE = { + CreatedOnToolsVersion = 16.1; + }; + C1049185261301530054AC9E = { + CreatedOnToolsVersion = 12.4; + LastSwiftMigration = 1240; + }; + C1850EB425F38A2D003D506A = { + CreatedOnToolsVersion = 12.4; + TestTargetID = 1D6058900D05DD3D006BFB54; + }; + }; + }; + buildConfigurationList = C01FCF4E08A954540054247B /* Build configuration list for PBXProject "Monal" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 1; + knownRegions = ( + Base, + de, + "es-419", + fr, + es, + ja, + it, + nl, + pl, + he, + ru, + hi, + "pt-PT", + "pt-BR", + ro, + vi, + uk, + "zh-Hant", + en, + ne, + "zh-Hant-HK", + ar, + fi, + sv, + tr, + da, + cs, + el, + fa, + "nb-NO", + "zh-Hans", + pa, + ko, + eu, + "es-AR", + ); + mainGroup = 29B97314FDCFA39411CA2CEA /* CustomTemplate */; + packageReferences = ( + C1F5C7AD2777638B0001F295 /* XCRemoteSwiftPackageReference "swift-collections" */, + 841898A82957712000FEC77D /* XCRemoteSwiftPackageReference "ViewExtractor" */, + 849ADF3D2BACF0360009BCD7 /* XCRemoteSwiftPackageReference "cocoalumberjack" */, + 84F194CF2C15197200F0A994 /* XCRemoteSwiftPackageReference "FrameUp" */, + 84E231F12C16A9CE00735FB7 /* XCRemoteSwiftPackageReference "SVGView" */, + 8418B5652C87E0ED006FAF60 /* XCRemoteSwiftPackageReference "Chat" */, + ); + productRefGroup = 19C28FACFE9D520D11CA2CBB /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7E995F052CEAC4B8005B30EE /* another.im */, + 1D6058900D05DD3D006BFB54 /* Monal */, + 26AA70102146BBB800598605 /* shareSheet */, + 260773BF232FC4E800BFD50F /* NotificationService */, + 26CC579123A0867400ABB92A /* monalxmpp */, + C1850EB425F38A2D003D506A /* MonalUITests */, + C1049185261301530054AC9E /* MonalXMPPUnitTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1D60588D0D05DD3D006BFB54 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 262E51931AD8CAC600788351 /* MLButtonCell.xib in Resources */, + 2601D9CB0FBF25EF004DB939 /* sworim.sqlite in Resources */, + 264A7E751AA7263600E860E3 /* MLSwitchCell.xib in Resources */, + 848C73E02BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */, + 26B0CA8B21AE410E0080B133 /* AlertSounds in Resources */, + 842790852A32D16D005C18CC /* CallSounds in Resources */, + 845836BA2C49F36300B11EC5 /* Quicksy Launch Screen.storyboard in Resources */, + 26470F521835C4080069E3E0 /* Media.xcassets in Resources */, + 26B2A4BB1B73061400272E63 /* Images.xcassets in Resources */, + 26E8462824EABAED00ECE419 /* Main.storyboard in Resources */, + 26158AF41FFA8A6A00E53BDC /* opensource.html in Resources */, + C1E4654824EE517000CA5AAF /* Localizable.strings in Resources */, + 262E51981AD8CB7200788351 /* MLTextInputCell.xib in Resources */, + 2649CE531C51B84C00CD1E80 /* Launch Screen.storyboard in Resources */, + 26F9794D1ACAC73A0008E005 /* MLContactCell.xib in Resources */, + 26E8462224EABAD600ECE419 /* Settings.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 260773BE232FC4E800BFD50F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 845EFFBD2918721800C1E03E /* Localizable.strings in Resources */, + C1D7D7B0283FB4E700401389 /* Media.xcassets in Resources */, + 848C73E22BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */, + C1D7D7AF283FB4E500401389 /* Images.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 26AA700F2146BBB800598605 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 848C73E12BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */, + D09B51F62C7F30DD008D725B /* Images.xcassets in Resources */, + 845EFFBE2918723D00C1E03E /* Localizable.strings in Resources */, + 26AA70182146BBB900598605 /* iosShare.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 26CC579023A0867400ABB92A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7E995F042CEAC4B8005B30EE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E995F272CEAC5D2005B30EE /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C1049184261301530054AC9E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C1850EB325F38A2D003D506A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0105BB524F492059A59FAB32 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Monal-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8FC0217E357D67979CAFB523 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MonalUITests/Pods-MonalUITests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MonalUITests/Pods-MonalUITests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MonalUITests/Pods-MonalUITests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + A0AAAD1CC9A02DA53A59DF82 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-MonalUITests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + A19DA25BC6A7C3D2C394D914 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-another.im/Pods-another.im-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-another.im/Pods-another.im-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-another.im/Pods-another.im-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + A9E041FD8051B08903221D89 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-another.im-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AEFD0BBC538F58642A554A94 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-shareSheet-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + B79D5724FB4B51022BCFD6E9 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MonalXMPPUnitTests/Pods-MonalXMPPUnitTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C19CC5E028E85527007AD33E /* Fix catalyst aps-enviromnent */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 8; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Fix catalyst aps-enviromnent"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 1; + shellPath = /bin/sh; + shellScript = "# https://developer.apple.com/forums/thread/714011\nentry=$(/usr/libexec/PlistBuddy -c \"Print :aps-environment\" \"$TARGET_TEMP_DIR/$FULL_PRODUCT_NAME.xcent\")\nif [ \"$entry\" == \"\" ]; then\n /usr/libexec/PlistBuddy -c \"Add :aps-environment string production\" \"$TARGET_TEMP_DIR/$FULL_PRODUCT_NAME.xcent\"\n echo \"Added aps-environment in $TARGET_TEMP_DIR/$FULL_PRODUCT_NAME.xcent\"\nelse\n echo \"aps-environment was present in $TARGET_TEMP_DIR/$FULL_PRODUCT_NAME.xcent\"\nfi\n"; + }; + D180E765B155419143F176E9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-monalxmpp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E09BC3B194F91D0DE538C310 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Monal/Pods-Monal-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Monal/Pods-Monal-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Monal/Pods-Monal-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + E29F37602913131122522B1F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-NotificationService-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EF65EEAA25C8D7FEE6305039 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-MonalXMPPUnitTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1D60588E0D05DD3D006BFB54 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 20D8C65E2C3C37FE00E6BDA2 /* MediaGallery.swift in Sources */, + 848717F3295ED64600B8D288 /* MLCall.m in Sources */, + 26D89F061A890672009B147C /* MLSwitchCell.m in Sources */, + C18967C72B81F61B0073C7C5 /* ChannelMemberList.swift in Sources */, + C114D13D2B15B903000FB99F /* ContactEntry.swift in Sources */, + 841B6F1A297B18720074F9B7 /* AccountPicker.swift in Sources */, + 3D65B791272350F0005A30F4 /* SwiftuiHelpers.swift in Sources */, + C1A80DA424D9552400B99E01 /* MLChatViewHelper.m in Sources */, + C117F7E22B0863B3001F2BC6 /* ContactPicker.swift in Sources */, + 1D60589B0D05DD56006BFB54 /* main.m in Sources */, + 1D3623260D0F684500981E51 /* MonalAppDelegate.m in Sources */, + 26158AF21FFA6E4500E53BDC /* MLWebViewController.m in Sources */, + C1943A4C25309A9D0036172F /* MLReloadCell.m in Sources */, + 262E51971AD8CB7200788351 /* MLTextInputCell.m in Sources */, + 20ED55852BADDA5C0005783E /* GeneralSettings.swift in Sources */, + 84E55E7D2964424E003E191A /* ActiveChatsViewController.m in Sources */, + C1E8A7F72B8E47C300760220 /* EditGroupSubject.swift in Sources */, + 263DFAC32187D0E00038E716 /* MLLinkCell.m in Sources */, + 3D65B78D27234B74005A30F4 /* ContactDetails.swift in Sources */, + E89DD32525C6626400925F62 /* MLFileTransferDataCell.m in Sources */, + E89DD32825C6626400925F62 /* MLFileTransferTextCell.m in Sources */, + 84BBAECA2C42D272009492E2 /* Quicksy_RegisterAccount.swift in Sources */, + 26B0CA8921AE2E3C0080B133 /* MLSoundsTableViewController.m in Sources */, + 84D31CE628653B83006D7926 /* WebRTCClient.swift in Sources */, + 54F0B81C282316F5003664BD /* RegisterAccount.swift in Sources */, + 841B6F1C297B3CFC0074F9B7 /* AVCallUI.swift in Sources */, + C15489B925680BBE00BBA2F0 /* MLQRCodeScanner.swift in Sources */, + 2609B5291FD5B26800F09FA1 /* MLSplitViewDelegate.m in Sources */, + 3D27D956290B0BB60014748B /* AddContactMenu.swift in Sources */, + 261A628B176C159000059090 /* AccountListController.m in Sources */, + 26FE3BCB1C61A6C3003CC230 /* MLResizingTextView.m in Sources */, + D02192F32C89BB3800202A59 /* BlockedUsers.swift in Sources */, + 54F0B81928231691003664BD /* WelcomeLogIn.swift in Sources */, + E8CF9CC726249640001A1952 /* MLSettingsAboutViewController.m in Sources */, + C10490492612ED2F0054AC9E /* MLEmoji.swift in Sources */, + 20D3611C2C10E12500E46587 /* BoardingCards.swift in Sources */, + 3D06A515281FFCC000DDAE90 /* NotificationDebugging.swift in Sources */, + 845D636B2AD4AEDA0066EFFB /* MediaViewer.swift in Sources */, + 2636C43F177BD58C001CA71F /* XMPPEdit.m in Sources */, + 84FC375928981A5600634E3E /* PasswordMigration.swift in Sources */, + E89DD32625C6626400925F62 /* MLFileTransferVideoCell.m in Sources */, + 3DC5035C2822F5220064C8A7 /* OmemoKeysView.swift in Sources */, + 34E58B4B2C68E7BC009A1634 /* ContactsView.swift in Sources */, + 262797AF178A577300B85D94 /* MLContactCell.m in Sources */, + D0FA79B12C7E5C7400216D2A /* ServerDetails.swift in Sources */, + 2696EED21791245A00BC54B8 /* chatViewController.m in Sources */, + C12436142434AB5D00B8F074 /* MLAttributedLabel.m in Sources */, + 3D85E587282AE523006F5B3A /* OmemoQrCodeView.swift in Sources */, + 849A53E4287135B2007E941A /* MLVoIPProcessor.m in Sources */, + C117F7E12B086390001F2BC6 /* CreateGroupMenu.swift in Sources */, + 952EBC802BAF72F300183DBF /* DebugView.swift in Sources */, + 3D7D352328626CB80042C5E5 /* LoadingOverlay.swift in Sources */, + 26D7C05E23D6AFD800CA123C /* MLChatInputContainer.m in Sources */, + 262AEFE820AE756800498F82 /* MLMAMPrefTableViewController.m in Sources */, + 26AAE285179F7B0200271345 /* MLSettingCell.m in Sources */, + 2664D28523F2312400CD4085 /* MLAccountPickerViewController.m in Sources */, + 843AD3AB2AA55CE20036844D /* MLOgHtmlParser.swift in Sources */, + 3D27D958290B0BC80014748B /* ContactRequestsMenu.swift in Sources */, + 3D5A91422842B4AE008CE57E /* MemberList.swift in Sources */, + 849248492AD4CEC400986C1A /* ZoomableContainer.swift in Sources */, + 2644D4921FF0064C00F46AB5 /* MLChatImageCell.m in Sources */, + 26AAAADC2295742500200433 /* MLPasswordChangeTableViewController.m in Sources */, + 8441EFF92921B53500E851E9 /* BackgroundSettings.swift in Sources */, + 841898AC2957DBAD00FEC77D /* RichAlert.swift in Sources */, + 840E23CA28ADA56900A7FAC9 /* MLUploadQueueCell.m in Sources */, + 848227912C4A6194003CCA33 /* MLPlaceholderViewController.m in Sources */, + 262E51921AD8CAC600788351 /* MLButtonCell.m in Sources */, + 841EE4302A426F2300D3AF14 /* MLCrashReporter.m in Sources */, + E8DED06225388BE8003167FF /* MLSearchViewController.m in Sources */, + C1F5C7AC2777621B0001F295 /* ContactResources.swift in Sources */, + E89DD32725C6626400925F62 /* MLFileTransferFileViewController.m in Sources */, + C1414E9D24312F0100948788 /* MLChatMapsCell.m in Sources */, + 54391BBB273499E80050DAFB /* UIColor+Extension.m in Sources */, + 2644D4951FF046E800F46AB5 /* MLBaseCell.m in Sources */, + 268DD58617C4541000C673A9 /* MLChatCell.m in Sources */, + 2644D4991FF29E5600F46AB5 /* MLSettingsTableViewController.m in Sources */, + 34BC08122C5E9BE30099FB85 /* ContentUnavailableShimView.swift in Sources */, + 846DF27C2937FAA600AAB9C0 /* ChatPlaceholder.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 260773BC232FC4E800BFD50F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 260773C4232FC4E800BFD50F /* NotificationService.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 26AA700D2146BBB800598605 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 26AA70152146BBB900598605 /* ShareViewController.m in Sources */, + 263DFAC02183D7160038E716 /* MLSelectionController.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 26CC578E23A0867400ABB92A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5427C94E276A6BE1003217D5 /* UIColor+Extension.m in Sources */, + 540E13A524CF6A8C0038FDA0 /* MLNotificationManager.m in Sources */, + 54D2308424CB10EE00638D65 /* MLLogFileManager.m in Sources */, + 26CC57B323A086CC00ABB92A /* XMPPIQ.m in Sources */, + C1613B5B2520723D0062C0C2 /* MLBasePaser.m in Sources */, + C10490EB2612F3E00054AC9E /* EncryptedPayload.swift in Sources */, + 54179CC0251CBAFA008F398E /* XMPPStanza.m in Sources */, + 26CC57CB23A0892800ABB92A /* MLPresenceProcessor.m in Sources */, + 26CC57A423A086AA00ABB92A /* MLXMPPServer.m in Sources */, + 540A3FAD24D674BD0008965D /* MLSQLite.m in Sources */, + 26CC57CA23A0892800ABB92A /* MLIQProcessor.m in Sources */, + 26CC57D323A08D7100ABB92A /* DataLayer.m in Sources */, + 26CC57A323A086AA00ABB92A /* MLDNSLookup.m in Sources */, + C10490E32612F3D00054AC9E /* MLCrypto.swift in Sources */, + C1F5C7AA2775DA000001F295 /* MLContactSoftwareVersionInfo.m in Sources */, + 26CC57D423A08D7100ABB92A /* MLImageManager.m in Sources */, + 540E13A024CDCDB30038FDA0 /* MLProcessLock.m in Sources */, + 26CC57C823A0892100ABB92A /* MLMessage.m in Sources */, + 848904A9289C82C30097E19C /* SCRAM.m in Sources */, + 26CC57A623A086AA00ABB92A /* MLXMPPConnection.m in Sources */, + 540BD0D424D8D2000087A743 /* IPC.m in Sources */, + 8418B55E2C7EC6B7006FAF60 /* Quicksy_Country.m in Sources */, + 84C1CD502A8C764D007076ED /* SwiftHelpers.swift in Sources */, + 541B6AB4262BC9040038B936 /* MLStream.m in Sources */, + 84FC37552897521500634E3E /* snprintf.m in Sources */, + 26CC57DA23A08DD800ABB92A /* MLXMPPManager.m in Sources */, + 26CC57B823A0876300ABB92A /* MLHTTPRequest.m in Sources */, + 26CC57A223A086AA00ABB92A /* xmpp.m in Sources */, + 26CC57B423A086CC00ABB92A /* XMPPPresence.m in Sources */, + 541E4CC0254AA0E700FD7B28 /* MLHandler.m in Sources */, + C158D41425A0AC630005AA40 /* MLMucProcessor.m in Sources */, + C16D18362792A4AF00F869A0 /* DataLayerMigrations.m in Sources */, + 54507CE5255D8C14007092F4 /* MLFiletransfer.m in Sources */, + 542CF3FF2763314F002C3710 /* hsluv.c in Sources */, + 8420EA9D2915E5100038FF40 /* OmemoState.m in Sources */, + 389E298C25E901CA009A5268 /* MLAudioRecoderManager.m in Sources */, + 84C1CD522A8F617F007076ED /* MLStreamRedirect.m in Sources */, + 26CC57C923A0892800ABB92A /* MLMessageProcessor.m in Sources */, + C18E757C245E8AE900AE8FB7 /* MLPipe.m in Sources */, + 8418B5642C7ECFD6006FAF60 /* HelperTools+Quicksy_CountryCodes.m in Sources */, + 26CC57D523A08D7100ABB92A /* MLSignalStore.m in Sources */, + C1C839DE24F15DF800BBCF17 /* MLOMEMO.m in Sources */, + 26CC57B223A086CC00ABB92A /* MLXMLNode.m in Sources */, + 26CC57A523A086AA00ABB92A /* MLXMPPIdentity.m in Sources */, + 54A22D2526185C2900B56EAD /* MLNotificationQueue.m in Sources */, + 54E594BE2523C34B00E4172B /* MLPubSub.m in Sources */, + 26CC57B723A0876300ABB92A /* MLEncryptedPayload.m in Sources */, + 26CC57B523A086CC00ABB92A /* XMPPMessage.m in Sources */, + 26CC57B623A0876300ABB92A /* AESGcm.m in Sources */, + 5455BC3324EB776F0024D80F /* MLUDPLogger.m in Sources */, + 541E4CC7254D370200FD7B28 /* MLPubSubProcessor.m in Sources */, + 26CC57C723A0892100ABB92A /* MLContact.m in Sources */, + 540F625F24BA951E0008A6D8 /* HelperTools.m in Sources */, + 38720923251EDE07001837EB /* MLXEPSlashMeHandler.m in Sources */, + 844921EA2C29F9A000B99A9C /* MLDelayableTimer.m in Sources */, + 544656BB2534910D006B2953 /* XMPPDataForm.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7E995F022CEAC4B8005B30EE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E995F242CEAC5D2005B30EE /* AnotherIMApp.swift in Sources */, + 7E995F252CEAC5D2005B30EE /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C1049182261301530054AC9E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C1049189261301530054AC9E /* MonalXMPPUnitTests.swift in Sources */, + C1B940EA26144AF500E9D290 /* AESGCMTest.m in Sources */, + C1049199261301710054AC9E /* MLCryptoTest.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C1850EB125F38A2D003D506A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C1850EB825F38A2D003D506A /* MonalUITests.swift in Sources */, + C1850EC625F3C5EB003D506A /* TestHelper.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2638008A2374814000144929 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 260773BF232FC4E800BFD50F /* NotificationService */; + targetProxy = 263800892374814000144929 /* PBXContainerItemProxy */; + }; + 26AA701B2146BBB900598605 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 26AA70102146BBB800598605 /* shareSheet */; + targetProxy = 26AA701A2146BBB900598605 /* PBXContainerItemProxy */; + }; + 26CC579823A0867400ABB92A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 26CC579123A0867400ABB92A /* monalxmpp */; + targetProxy = 26CC579723A0867400ABB92A /* PBXContainerItemProxy */; + }; + 26CC57E823A08F3A00ABB92A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 26CC579123A0867400ABB92A /* monalxmpp */; + targetProxy = 26CC57E723A08F3A00ABB92A /* PBXContainerItemProxy */; + }; + 26CC57EA23A08F3F00ABB92A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 26CC579123A0867400ABB92A /* monalxmpp */; + targetProxy = 26CC57E923A08F3F00ABB92A /* PBXContainerItemProxy */; + }; + 7E995F2E2CEAC9A0005B30EE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 26CC579123A0867400ABB92A /* monalxmpp */; + targetProxy = 7E995F2D2CEAC9A0005B30EE /* PBXContainerItemProxy */; + }; + C104918D261301530054AC9E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 26CC579123A0867400ABB92A /* monalxmpp */; + targetProxy = C104918C261301530054AC9E /* PBXContainerItemProxy */; + }; + C1850EBB25F38A2D003D506A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1D6058900D05DD3D006BFB54 /* Monal */; + targetProxy = C1850EBA25F38A2D003D506A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 26AA70162146BBB900598605 /* iosShare.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 26AA70172146BBB900598605 /* Base */, + 26E8462F24EABC0800ECE419 /* de */, + 26E8463424EABCF100ECE419 /* es-419 */, + 26E8463924EABD6200ECE419 /* fr */, + 26E8463E24EABFE300ECE419 /* es */, + 26E8464324EAC05700ECE419 /* ja */, + 26E8464824EAC05E00ECE419 /* it */, + 26E8465124EAC0B100ECE419 /* nl */, + 26E8465224EAC0B400ECE419 /* pl */, + 26E8465C24EAC0EE00ECE419 /* he */, + 26E8466124EAC10300ECE419 /* ru */, + 26E8466624EAC11C00ECE419 /* hi */, + 26E8466B24EAC13E00ECE419 /* pt-PT */, + 26E8467024EAC15000ECE419 /* pt-BR */, + 26E8467524EAC16D00ECE419 /* ro */, + 26E8467E24EAC1BB00ECE419 /* vi */, + 266A0B7924EAC35B00875DF8 /* uk */, + 26FC619024EB2BF40094C302 /* zh-Hant */, + 26FC619524EB6C270094C302 /* en */, + C1C8399E24F0EF9B00BBCF17 /* ar */, + C1C839A024F0EFA000BBCF17 /* cs */, + C1C839A224F0EFA400BBCF17 /* da */, + C1C839A424F0EFAC00BBCF17 /* fi */, + C1C839A624F0EFB000BBCF17 /* el */, + C1C839A824F0EFB400BBCF17 /* ne */, + C1C839A924F0EFB700BBCF17 /* nb-NO */, + C1C839AB24F0EFB900BBCF17 /* fa */, + C1C839AD24F0EFC900BBCF17 /* sv */, + C1C839AF24F0EFD200BBCF17 /* tr */, + C1C839C824F1255600BBCF17 /* zh-Hant-HK */, + C13CF81D261A00FE005452E5 /* zh-Hans */, + C132EA9B26C92E9000BB9A67 /* pa */, + C15A4E5F279D2AC80055CD11 /* ko */, + C1E856A828DECF5F00B104E9 /* eu */, + 84F194C52C0FE78B00F0A994 /* es-AR */, + ); + name = iosShare.storyboard; + sourceTree = ""; + }; + 26E8462424EABAD600ECE419 /* Settings.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 26E8462324EABAD600ECE419 /* Base */, + 26E8462D24EABC0300ECE419 /* de */, + 26E8463224EABCEA00ECE419 /* es-419 */, + 26E8463724EABD5300ECE419 /* fr */, + 26E8463C24EABFE000ECE419 /* es */, + 26E8464124EAC05400ECE419 /* ja */, + 26E8464624EAC05B00ECE419 /* it */, + 26E8464B24EAC09D00ECE419 /* nl */, + 26E8464F24EAC0AE00ECE419 /* pl */, + 26E8465A24EAC0EA00ECE419 /* he */, + 26E8465F24EAC0FF00ECE419 /* ru */, + 26E8466424EAC11800ECE419 /* hi */, + 26E8466924EAC13800ECE419 /* pt-PT */, + 26E8466E24EAC14D00ECE419 /* pt-BR */, + 26E8467324EAC16900ECE419 /* ro */, + 26E8467824EAC1A200ECE419 /* vi */, + 266A0B7C24EAC36600875DF8 /* uk */, + 26FC618E24EB2BF10094C302 /* zh-Hant */, + 26FC619324EB6C250094C302 /* en */, + C1C8397824F0EEF900BBCF17 /* da */, + C1C8397A24F0EF0000BBCF17 /* cs */, + C1C8397C24F0EF0400BBCF17 /* fi */, + C1C8397E24F0EF0A00BBCF17 /* ne */, + C1C8398024F0EF1500BBCF17 /* ar */, + C1C8398224F0EF4700BBCF17 /* el */, + C1C8398324F0EF4A00BBCF17 /* nb-NO */, + C1C8398524F0EF4F00BBCF17 /* fa */, + C1C8398724F0EF5600BBCF17 /* tr */, + C1C8398924F0EF5A00BBCF17 /* sv */, + C1C839C424F1254200BBCF17 /* zh-Hant-HK */, + C13CF81B261A00FD005452E5 /* zh-Hans */, + C132EA9626C92DD900BB9A67 /* pa */, + C15A4E5D279D2AC70055CD11 /* ko */, + C1E856A728DECF5F00B104E9 /* eu */, + 84F194C42C0FE74500F0A994 /* es-AR */, + ); + name = Settings.storyboard; + sourceTree = ""; + }; + 26E8462A24EABAED00ECE419 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 26E8462924EABAED00ECE419 /* Base */, + 26E8462B24EABBFC00ECE419 /* de */, + 26E8463024EABCDE00ECE419 /* es-419 */, + 26E8463524EABD4100ECE419 /* fr */, + 26E8463A24EABFD900ECE419 /* es */, + 26E8463F24EAC04400ECE419 /* ja */, + 26E8464424EAC05800ECE419 /* it */, + 26E8464924EAC08800ECE419 /* nl */, + 26E8464D24EAC0AC00ECE419 /* pl */, + 26E8465824EAC0E300ECE419 /* he */, + 26E8465D24EAC0F900ECE419 /* ru */, + 26E8466224EAC11100ECE419 /* hi */, + 26E8466724EAC13200ECE419 /* pt-PT */, + 26E8466C24EAC14A00ECE419 /* pt-BR */, + 26E8467124EAC16200ECE419 /* ro */, + 26E8467624EAC19C00ECE419 /* vi */, + 266A0B7A24EAC35F00875DF8 /* uk */, + 26FC618B24EB2BEE0094C302 /* zh-Hant */, + 26FC619124EB6C220094C302 /* en */, + C1C8394E24F0EDFD00BBCF17 /* ar */, + C1C8395024F0EE0300BBCF17 /* zh-Hant-HK */, + C1C8395224F0EE0A00BBCF17 /* cs */, + C1C8395424F0EE0D00BBCF17 /* da */, + C1C8395624F0EE1800BBCF17 /* sv */, + C1C8395824F0EE1B00BBCF17 /* fa */, + C1C8395A24F0EE2000BBCF17 /* ne */, + C1C8395C24F0EE2400BBCF17 /* fi */, + C1C8395E24F0EE2C00BBCF17 /* el */, + C1C8396324F0EEBA00BBCF17 /* nb-NO */, + C1C839C024F1252B00BBCF17 /* tr */, + C13CF819261A00D0005452E5 /* zh-Hans */, + C132EA9426C92DD900BB9A67 /* pa */, + C15A4E5B279D2AC60055CD11 /* ko */, + C1E856A628DECF5F00B104E9 /* eu */, + 84F194C32C0FE70900F0A994 /* es-AR */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + C1E4654624EE517000CA5AAF /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + C1E4654724EE517000CA5AAF /* de */, + C1E4654924EE51EC00CA5AAF /* Base */, + C1E4654A24EE520D00CA5AAF /* fr */, + C1E4654B24EE520D00CA5AAF /* ru */, + C1E4654C24EE520D00CA5AAF /* ne */, + C1E4654D24EE520D00CA5AAF /* pl */, + C1E4654E24EE520D00CA5AAF /* he */, + C1E4654F24EE520D00CA5AAF /* zh-Hant-HK */, + C1E4655024EE520D00CA5AAF /* ro */, + C1E4655124EE520D00CA5AAF /* es */, + C1E4655224EE520D00CA5AAF /* ar */, + C1E4655324EE520E00CA5AAF /* fi */, + C1E4655424EE520E00CA5AAF /* sv */, + C1E4655524EE520E00CA5AAF /* ja */, + C1E4655624EE520E00CA5AAF /* uk */, + C1E4655724EE520E00CA5AAF /* tr */, + C1E4655824EE520E00CA5AAF /* hi */, + C1E4655924EE520E00CA5AAF /* es-419 */, + C1E4655A24EE520E00CA5AAF /* it */, + C1E4655B24EE520F00CA5AAF /* vi */, + C1E4655C24EE520F00CA5AAF /* da */, + C1E4655D24EE520F00CA5AAF /* nl */, + C1E4655E24EE520F00CA5AAF /* cs */, + C1E4655F24EE520F00CA5AAF /* el */, + C1E4656024EE520F00CA5AAF /* fa */, + C1C8395F24F0EE6700BBCF17 /* nb-NO */, + C13E640625BD405700763D6F /* zh-Hant */, + C13E640825BD406600763D6F /* pt-BR */, + C13E640925BD406700763D6F /* pt-PT */, + C13CF81E261A00FE005452E5 /* zh-Hans */, + C132EA9926C92DDA00BB9A67 /* pa */, + C15A4E60279D2AC80055CD11 /* ko */, + C1E856A928DECF5F00B104E9 /* eu */, + 84F194C62C0FE79000F0A994 /* es-AR */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 1D6058940D05DD3E006BFB54 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7FA9582E4CC566FE5466C557 /* Pods-Monal.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_LINK_OBJC_RUNTIME = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = NO; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_VEXING_PARSE = YES_ERROR; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_ENTITLEMENTS = Monal.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = Monal.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Monal.macos.entitlements; + COMPILER_INDEX_STORE_ENABLE = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + ENABLE_BITCODE = NO; + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)", + ); + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = MonalSourceCodePrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + "GCC_PREPROCESSOR_DEFINITIONS[arch=*]" = "$(inherited)"; + GCC_THREADSAFE_STATICS = NO; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_VERSION = com.apple.compilers.llvm.clang.1_0; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_PEDANTIC = NO; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + INFOPLIST_FILE = "Monal-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Monal; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal"; + PRODUCT_MODULE_NAME = Monal; + PRODUCT_NAME = Monal; + PROVISIONING_PROFILE = ""; + "PROVISIONING_PROFILE[sdk=iphoneos*]" = ""; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/Monal-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "Monal-Swift.h"; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 260773C9232FC4E800BFD50F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 797F93A1B3C6B4735F2ABE7D /* Pods-NotificationService.debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CODE_SIGN_ENTITLEMENTS = ""; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = NotificationService/NotificationService.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = NotificationService/NotificationService.macos.entitlements; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.notificationService; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal.notificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 260773CA232FC4E800BFD50F /* Adhoc */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B8155E63F8DE80FF36D0B3B7 /* Pods-NotificationService.adhoc.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CODE_SIGN_ENTITLEMENTS = ""; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = NotificationService/NotificationService.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = NotificationService/NotificationService.macos.entitlements; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.notificationService; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal.notificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Adhoc; + }; + 260773CB232FC4E800BFD50F /* AppStore */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 79A6AA4819B69B5FFFA28236 /* Pods-NotificationService.appstore.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CODE_SIGN_ENTITLEMENTS = ""; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = NotificationService/NotificationService.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = NotificationService/NotificationService.macos.entitlements; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.notificationService; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal.notificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = AppStore; + }; + 2675EF5918B98C2D0059C5C3 /* AppStore */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = monalGreen; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_STATIC_ANALYZER_MODE_ON_ANALYZE_ACTION = shallow; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_COMPLETION_HANDLER_MISUSE = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FLOAT_CONVERSION = NO; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = NO; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = S8D843U34Y; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_GENERATE_DEBUGGING_SYMBOLS = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = NO; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = NO; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LLVM_LTO = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 0.0.1; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTS_MACCATALYST = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = AppStore; + }; + 2675EF5A18B98C2D0059C5C3 /* AppStore */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1D46F251C198E3D8FA55692F /* Pods-Monal.appstore.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_LINK_OBJC_RUNTIME = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = NO; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_VEXING_PARSE = YES_ERROR; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_ENTITLEMENTS = Monal.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = Monal.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Monal.macos.entitlements; + COMPILER_INDEX_STORE_ENABLE = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + ENABLE_BITCODE = NO; + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_PREVIEWS = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)", + ); + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = MonalSourceCodePrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_THREADSAFE_STATICS = NO; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_VERSION = com.apple.compilers.llvm.clang.1_0; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_PEDANTIC = NO; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + INFOPLIST_FILE = "Monal-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Monal; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal"; + PRODUCT_MODULE_NAME = Monal; + PRODUCT_NAME = Monal; + PROVISIONING_PROFILE = ""; + "PROVISIONING_PROFILE[sdk=iphoneos*]" = ""; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/Monal-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "Monal-Swift.h"; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = AppStore; + }; + 26AA701D2146BBB900598605 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 21E99538324C14220843F325 /* Pods-shareSheet.debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = shareSheet.entitlements; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = "$(SRCROOT)/shareSheet-iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.shareSheet; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal.shareSheet"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 26AA701E2146BBB900598605 /* Adhoc */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0EC0A5305E72C0F7F9E1CDEF /* Pods-shareSheet.adhoc.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = shareSheet.entitlements; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = "$(SRCROOT)/shareSheet-iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.shareSheet; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal.shareSheet"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Adhoc; + }; + 26AA701F2146BBB900598605 /* AppStore */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 061EF1BEDEE7A71FDF9AB402 /* Pods-shareSheet.appstore.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = shareSheet.entitlements; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = "$(SRCROOT)/shareSheet-iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.shareSheet; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal.shareSheet"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = AppStore; + }; + 26CC579C23A0867400ABB92A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B55DCA2ABBDB6E635D46D69A /* Pods-monalxmpp.debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_INPUT_FILETYPE = automatic; + GCC_OPTIMIZATION_LEVEL = 0; + INFOPLIST_FILE = monalxmpp/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.Monal.monalxmpp; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = monalxmpp/; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "monalxmpp-Swift.h"; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 26CC579D23A0867400ABB92A /* Adhoc */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E06DB4446BAE2F9C0192D055 /* Pods-monalxmpp.adhoc.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_INPUT_FILETYPE = automatic; + GCC_NO_COMMON_BLOCKS = YES; + INFOPLIST_FILE = monalxmpp/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.Monal.monalxmpp; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = monalxmpp/; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "monalxmpp-Swift.h"; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSION_INFO_PREFIX = ""; + }; + name = Adhoc; + }; + 26CC579E23A0867400ABB92A /* AppStore */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AAFC73E987D41BA8D91E9F95 /* Pods-monalxmpp.appstore.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_INPUT_FILETYPE = automatic; + GCC_NO_COMMON_BLOCKS = YES; + INFOPLIST_FILE = monalxmpp/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.Monal.monalxmpp; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = monalxmpp/; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "monalxmpp-Swift.h"; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSION_INFO_PREFIX = ""; + }; + name = AppStore; + }; + 26E74FBD17B06D2200FD91AE /* Adhoc */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = monalGreen; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_STATIC_ANALYZER_MODE_ON_ANALYZE_ACTION = shallow; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_COMPLETION_HANDLER_MISUSE = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FLOAT_CONVERSION = NO; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = NO; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = S8D843U34Y; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_GENERATE_DEBUGGING_SYMBOLS = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = NO; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = NO; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LLVM_LTO = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 0.0.1; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTS_MACCATALYST = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Adhoc; + }; + 26E74FBE17B06D2200FD91AE /* Adhoc */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AD0E234056402EE91A36D628 /* Pods-Monal.adhoc.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_LINK_OBJC_RUNTIME = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = NO; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_VEXING_PARSE = YES_ERROR; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_ENTITLEMENTS = Monal.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = Monal.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Monal.macos.entitlements; + COMPILER_INDEX_STORE_ENABLE = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + ENABLE_BITCODE = NO; + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_PREVIEWS = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)", + ); + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = MonalSourceCodePrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_THREADSAFE_STATICS = NO; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_VERSION = com.apple.compilers.llvm.clang.1_0; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_PEDANTIC = NO; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + INFOPLIST_FILE = "Monal-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Monal; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal"; + PRODUCT_MODULE_NAME = Monal; + PRODUCT_NAME = Monal; + PROVISIONING_PROFILE = ""; + "PROVISIONING_PROFILE[sdk=iphoneos*]" = ""; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/Monal-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "Monal-Swift.h"; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Adhoc; + }; + 7E995F112CEAC4BA005B30EE /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1936866375CABF471D3CE238 /* Pods-another.im.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = K78H7BT98L; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = another.im; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + /usr/lib/swift, + ); + LIBRARY_SEARCH_PATHS = ( + "$(SRC_ROOT)", + "$(inherited)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = im.narayana.anotherim.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 7E995F122CEAC4BA005B30EE /* Adhoc */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F8ACC07B95446BB8052933BF /* Pods-another.im.adhoc.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = K78H7BT98L; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = another.im; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + /usr/lib/swift, + ); + LIBRARY_SEARCH_PATHS = ( + "$(SRC_ROOT)", + "$(inherited)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = im.narayana.anotherim.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Adhoc; + }; + 7E995F132CEAC4BA005B30EE /* Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BC9E05245CF07072A35AE126 /* Pods-another.im.alpha.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = K78H7BT98L; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = another.im; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + /usr/lib/swift, + ); + LIBRARY_SEARCH_PATHS = ( + "$(SRC_ROOT)", + "$(inherited)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = im.narayana.anotherim.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Alpha; + }; + 7E995F142CEAC4BA005B30EE /* AppStore */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F2082C5D72E8D7D49B31FBEE /* Pods-another.im.appstore.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = K78H7BT98L; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = another.im; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + /usr/lib/swift, + ); + LIBRARY_SEARCH_PATHS = ( + "$(SRC_ROOT)", + "$(inherited)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = im.narayana.anotherim.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = AppStore; + }; + 7E995F152CEAC4BA005B30EE /* AppStore-Quicksy */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7D281334DB441077E42E3E89 /* Pods-another.im.appstore-quicksy.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = K78H7BT98L; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = another.im; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + /usr/lib/swift, + ); + LIBRARY_SEARCH_PATHS = ( + "$(SRC_ROOT)", + "$(inherited)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = im.narayana.anotherim.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = "AppStore-Quicksy"; + }; + 7E995F162CEAC4BA005B30EE /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3D8AAFBF5B865907983E9F59 /* Pods-another.im.beta.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = K78H7BT98L; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = another.im; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + /usr/lib/swift, + ); + LIBRARY_SEARCH_PATHS = ( + "$(SRC_ROOT)", + "$(inherited)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = im.narayana.anotherim.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Beta; + }; + C01FCF4F08A954540054247B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = monalGreen; + CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_STATIC_ANALYZER_MODE = deep; + CLANG_STATIC_ANALYZER_MODE_ON_ANALYZE_ACTION = deep; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_COMPLETION_HANDLER_MISUSE = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FLOAT_CONVERSION = NO; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = NO; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = S8D843U34Y; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_GENERATE_DEBUGGING_SYMBOLS = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = NO; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = NO; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LLVM_LTO = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 0.0.1; + ONLY_ACTIVE_ARCH = YES; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; + RUN_CLANG_STATIC_ANALYZER = YES; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Debug; + }; + C104918F261301530054AC9E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3EB7A7084FA9A8F68A3D251C /* Pods-MonalXMPPUnitTests.debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = MonalXMPPUnitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.MonalXMPPUnitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; + SWIFT_OBJC_BRIDGING_HEADER = "MonalXMPPUnitTests/MonalXMPPUnitTests-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C1049190261301530054AC9E /* Adhoc */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9705AFFB59AF72A9B79C1D7B /* Pods-MonalXMPPUnitTests.adhoc.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = MonalXMPPUnitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.MonalXMPPUnitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OBJC_BRIDGING_HEADER = "MonalXMPPUnitTests/MonalXMPPUnitTests-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Adhoc; + }; + C1049191261301530054AC9E /* AppStore */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2369191B3FCB2E941169A093 /* Pods-MonalXMPPUnitTests.appstore.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = MonalXMPPUnitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.MonalXMPPUnitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OBJC_BRIDGING_HEADER = "MonalXMPPUnitTests/MonalXMPPUnitTests-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = AppStore; + }; + C11C870B26A83C1D00B8DEA5 /* Beta */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = monalGreen; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_STATIC_ANALYZER_MODE_ON_ANALYZE_ACTION = shallow; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_COMPLETION_HANDLER_MISUSE = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FLOAT_CONVERSION = NO; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = NO; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = S8D843U34Y; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_GENERATE_DEBUGGING_SYMBOLS = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = NO; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = NO; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LLVM_LTO = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 0.0.1; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; + RUN_CLANG_STATIC_ANALYZER = YES; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Beta; + }; + C11C870D26A83C1D00B8DEA5 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FCADB10208409DD462AC921F /* Pods-Monal.beta.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_LINK_OBJC_RUNTIME = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = NO; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_VEXING_PARSE = YES_ERROR; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_ENTITLEMENTS = Monal.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = Monal.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Monal.macos.entitlements; + COMPILER_INDEX_STORE_ENABLE = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + ENABLE_BITCODE = NO; + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_PREVIEWS = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)", + ); + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = MonalSourceCodePrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_THREADSAFE_STATICS = NO; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_VERSION = com.apple.compilers.llvm.clang.1_0; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_PEDANTIC = NO; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + INFOPLIST_FILE = "Monal-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Monal; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal"; + PRODUCT_MODULE_NAME = Monal; + PRODUCT_NAME = Monal; + PROVISIONING_PROFILE = ""; + "PROVISIONING_PROFILE[sdk=iphoneos*]" = ""; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/Monal-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "Monal-Swift.h"; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Beta; + }; + C11C870E26A83C1D00B8DEA5 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6BCB9FB4EBEA3735D24A44DF /* Pods-shareSheet.beta.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = shareSheet.entitlements; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = "$(SRCROOT)/shareSheet-iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.shareSheet; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal.shareSheet"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Beta; + }; + C11C870F26A83C1D00B8DEA5 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4049F81F60EA5B7A57A4E9C6 /* Pods-NotificationService.beta.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CODE_SIGN_ENTITLEMENTS = ""; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = NotificationService/NotificationService.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = NotificationService/NotificationService.macos.entitlements; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.notificationService; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.monal.notificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Beta; + }; + C11C871026A83C1D00B8DEA5 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DC5DA2C9782510B3433FD50D /* Pods-monalxmpp.beta.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_INPUT_FILETYPE = automatic; + GCC_NO_COMMON_BLOCKS = YES; + INFOPLIST_FILE = monalxmpp/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.Monal.monalxmpp; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = monalxmpp/; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "monalxmpp-Swift.h"; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSION_INFO_PREFIX = ""; + }; + name = Beta; + }; + C11C871126A83C1D00B8DEA5 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7D6715099247A9CCC180EE30 /* Pods-MonalUITests.beta.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + DEAD_CODE_STRIPPING = NO; + INFOPLIST_FILE = MonalUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + TEST_TARGET_NAME = Monal; + VALIDATE_PRODUCT = YES; + }; + name = Beta; + }; + C11C871226A83C1D00B8DEA5 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4862C3A0242FB4F709B8F3FF /* Pods-MonalXMPPUnitTests.beta.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = MonalXMPPUnitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.MonalXMPPUnitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OBJC_BRIDGING_HEADER = "MonalXMPPUnitTests/MonalXMPPUnitTests-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Beta; + }; + C15D0B0D2B3EF70E00845061 /* AppStore-Quicksy */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = quicksyGreen; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_STATIC_ANALYZER_MODE_ON_ANALYZE_ACTION = shallow; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_COMPLETION_HANDLER_MISUSE = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FLOAT_CONVERSION = NO; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = NO; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = S8D843U34Y; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_GENERATE_DEBUGGING_SYMBOLS = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "IS_QUICKSY=1", + ); + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = NO; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = NO; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LLVM_LTO = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 0.0.1; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = IS_QUICKSY; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "AppStore-Quicksy"; + }; + C15D0B0E2B3EF70E00845061 /* AppStore-Quicksy */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 213F5BFD4599EC9317B99E97 /* Pods-Monal.appstore-quicksy.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = QuicksyAppIcon; + CLANG_LINK_OBJC_RUNTIME = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = NO; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_VEXING_PARSE = YES_ERROR; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_ENTITLEMENTS = Quicksy.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = Quicksy.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Quicksy.macos.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMPILER_INDEX_STORE_ENABLE = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = S8D843U34Y; + ENABLE_BITCODE = NO; + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_PREVIEWS = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)", + ); + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = MonalSourceCodePrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_THREADSAFE_STATICS = NO; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_VERSION = com.apple.compilers.llvm.clang.1_0; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_PEDANTIC = NO; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + INFOPLIST_FILE = "Quicksy-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Quicksy; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; + PRODUCT_BUNDLE_IDENTIFIER = "org.monal-im.prod.ios.quicksy"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.quicksy"; + PRODUCT_MODULE_NAME = Monal; + PRODUCT_NAME = Quicksy; + PROVISIONING_PROFILE = ""; + "PROVISIONING_PROFILE[sdk=iphoneos*]" = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/Monal-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "Monal-Swift.h"; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "AppStore-Quicksy"; + }; + C15D0B0F2B3EF70E00845061 /* AppStore-Quicksy */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9760CF4718351300C4256921 /* Pods-shareSheet.appstore-quicksy.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Quicksy.sharesheet.entitlements; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = "$(SRCROOT)/shareSheet-iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.monal-im.prod.ios.quicksy.shareSheet"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.quicksy.shareSheet"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "AppStore-Quicksy"; + }; + C15D0B102B3EF70E00845061 /* AppStore-Quicksy */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AA697C1F9B9637B86665DFF1 /* Pods-NotificationService.appstore-quicksy.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CODE_SIGN_ENTITLEMENTS = ""; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = NotificationService/Quicksy.NotificationService.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = NotificationService/Quicksy.NotificationService.macos.entitlements; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.monal-im.prod.ios.quicksy.notificationService"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "org.monal-im.prod.catalyst.quicksy.notificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "AppStore-Quicksy"; + }; + C15D0B112B3EF70E00845061 /* AppStore-Quicksy */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9899D670570190DCBE9EEDDB /* Pods-monalxmpp.appstore-quicksy.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_INPUT_FILETYPE = automatic; + GCC_NO_COMMON_BLOCKS = YES; + INFOPLIST_FILE = monalxmpp/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.Monal.monalxmpp; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = monalxmpp/; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "monalxmpp-Swift.h"; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSION_INFO_PREFIX = ""; + }; + name = "AppStore-Quicksy"; + }; + C15D0B122B3EF70E00845061 /* AppStore-Quicksy */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 26ABE9FB494A9E7F3044C695 /* Pods-MonalUITests.appstore-quicksy.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + DEAD_CODE_STRIPPING = NO; + INFOPLIST_FILE = MonalUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + TEST_TARGET_NAME = Monal; + VALIDATE_PRODUCT = YES; + }; + name = "AppStore-Quicksy"; + }; + C15D0B132B3EF70E00845061 /* AppStore-Quicksy */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B58835E4BBDCCB6BE1E8F0AE /* Pods-MonalXMPPUnitTests.appstore-quicksy.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = MonalXMPPUnitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.MonalXMPPUnitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OBJC_BRIDGING_HEADER = "MonalXMPPUnitTests/MonalXMPPUnitTests-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "AppStore-Quicksy"; + }; + C1850EBD25F38A2D003D506A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F7506FDE7A78EB0CAB14FF60 /* Pods-MonalUITests.debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + DEAD_CODE_STRIPPING = NO; + INFOPLIST_FILE = MonalUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + TEST_TARGET_NAME = Monal; + }; + name = Debug; + }; + C1850EBE25F38A2D003D506A /* Adhoc */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 39B989B9775C0725A810D271 /* Pods-MonalUITests.adhoc.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + DEAD_CODE_STRIPPING = NO; + INFOPLIST_FILE = MonalUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + TEST_TARGET_NAME = Monal; + VALIDATE_PRODUCT = YES; + }; + name = Adhoc; + }; + C1850EBF25F38A2D003D506A /* AppStore */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D8D2595B2BE453296E59F1AF /* Pods-MonalUITests.appstore.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + DEAD_CODE_STRIPPING = NO; + INFOPLIST_FILE = MonalUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + TEST_TARGET_NAME = Monal; + VALIDATE_PRODUCT = YES; + }; + name = AppStore; + }; + C1E1BCAE288BBAB00046AB47 /* Alpha */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = monalGreen; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_STATIC_ANALYZER_MODE_ON_ANALYZE_ACTION = shallow; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_COMPLETION_HANDLER_MISUSE = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_FLOAT_CONVERSION = NO; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = NO; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = S8D843U34Y; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_GENERATE_DEBUGGING_SYMBOLS = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "IS_ALPHA=1", + "DEBUG=1", + ); + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = NO; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = NO; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LLVM_LTO = NO; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 0.0.1; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; + RUN_CLANG_STATIC_ANALYZER = YES; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "IS_ALPHA DEBUG"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Alpha; + }; + C1E1BCAF288BBAB00046AB47 /* Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BFA9EFD7A8064201C81F52CF /* Pods-Monal.alpha.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AlphaAppIcon; + CLANG_LINK_OBJC_RUNTIME = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES; + CLANG_WARN_FRAMEWORK_INCLUDE_PRIVATE_FROM_PUBLIC = YES; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = NO; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_VEXING_PARSE = YES_ERROR; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_ENTITLEMENTS = Monal.Alpha.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = Monal.Alpha.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Monal.Alpha.macos.entitlements; + COMPILER_INDEX_STORE_ENABLE = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + ENABLE_BITCODE = NO; + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_PREVIEWS = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)", + ); + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = MonalSourceCodePrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_THREADSAFE_STATICS = NO; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_VERSION = com.apple.compilers.llvm.clang.1_0; + GCC_WARN_ABOUT_INVALID_OFFSETOF_MACRO = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_PEDANTIC = NO; + GCC_WARN_STRICT_SELECTOR_MATCH = NO; + INFOPLIST_FILE = "Monal.Alpha-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Monal.alpha; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; + PRODUCT_BUNDLE_IDENTIFIER = monal.alpha; + PRODUCT_MODULE_NAME = Monal; + PRODUCT_NAME = Monal.alpha; + PROVISIONING_PROFILE = ""; + "PROVISIONING_PROFILE[sdk=iphoneos*]" = ""; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/Monal-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "Monal-Swift.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Alpha; + }; + C1E1BCB0288BBAB00046AB47 /* Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6015D382ABCE0D788029D7A3 /* Pods-shareSheet.alpha.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Alpha.shareSheet.entitlements; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = "$(SRCROOT)/shareSheet-iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = monal.alpha.shareSheet; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Alpha; + }; + C1E1BCB1288BBAB00046AB47 /* Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 671D139EE64DB6AD9E1D8108 /* Pods-NotificationService.alpha.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CODE_SIGN_ENTITLEMENTS = ""; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = NotificationService/Alpha.NotificationService.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = NotificationService/Alpha.NotificationService.macos.entitlements; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + INFOPLIST_FILE = NotificationService/Alpha.Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = monal.alpha.NotificaionService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Alpha; + }; + C1E1BCB2288BBAB00046AB47 /* Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 39DB4C9159DA578D1A34990D /* Pods-monalxmpp.alpha.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CURRENT_PROJECT_VERSION = 0; + DEAD_CODE_STRIPPING = NO; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_INPUT_FILETYPE = automatic; + GCC_NO_COMMON_BLOCKS = YES; + INFOPLIST_FILE = monalxmpp/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.Monal.monalxmpp; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + STRIP_SWIFT_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = monalxmpp/; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "monalxmpp-Swift.h"; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSION_INFO_PREFIX = ""; + }; + name = Alpha; + }; + C1E1BCB3288BBAB00046AB47 /* Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5BACDACCFE405FE0C903C897 /* Pods-MonalUITests.alpha.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + DEAD_CODE_STRIPPING = NO; + INFOPLIST_FILE = MonalUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + TEST_TARGET_NAME = Monal; + VALIDATE_PRODUCT = YES; + }; + name = Alpha; + }; + C1E1BCB4288BBAB00046AB47 /* Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4E53581DEF864B229A09FA61 /* Pods-MonalXMPPUnitTests.alpha.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = MonalXMPPUnitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = G7YU7X7KRJ.SworIM.MonalXMPPUnitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_SWIFT_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OBJC_BRIDGING_HEADER = "MonalXMPPUnitTests/MonalXMPPUnitTests-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Alpha; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1D6058960D05DD3E006BFB54 /* Build configuration list for PBXNativeTarget "Monal" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1D6058940D05DD3E006BFB54 /* Debug */, + 26E74FBE17B06D2200FD91AE /* Adhoc */, + C1E1BCAF288BBAB00046AB47 /* Alpha */, + 2675EF5A18B98C2D0059C5C3 /* AppStore */, + C15D0B0E2B3EF70E00845061 /* AppStore-Quicksy */, + C11C870D26A83C1D00B8DEA5 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 260773CC232FC4E800BFD50F /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 260773C9232FC4E800BFD50F /* Debug */, + 260773CA232FC4E800BFD50F /* Adhoc */, + C1E1BCB1288BBAB00046AB47 /* Alpha */, + 260773CB232FC4E800BFD50F /* AppStore */, + C15D0B102B3EF70E00845061 /* AppStore-Quicksy */, + C11C870F26A83C1D00B8DEA5 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 26AA70202146BBB900598605 /* Build configuration list for PBXNativeTarget "shareSheet" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 26AA701D2146BBB900598605 /* Debug */, + 26AA701E2146BBB900598605 /* Adhoc */, + C1E1BCB0288BBAB00046AB47 /* Alpha */, + 26AA701F2146BBB900598605 /* AppStore */, + C15D0B0F2B3EF70E00845061 /* AppStore-Quicksy */, + C11C870E26A83C1D00B8DEA5 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 26CC579F23A0867400ABB92A /* Build configuration list for PBXNativeTarget "monalxmpp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 26CC579C23A0867400ABB92A /* Debug */, + 26CC579D23A0867400ABB92A /* Adhoc */, + C1E1BCB2288BBAB00046AB47 /* Alpha */, + 26CC579E23A0867400ABB92A /* AppStore */, + C15D0B112B3EF70E00845061 /* AppStore-Quicksy */, + C11C871026A83C1D00B8DEA5 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 7E995F172CEAC4BA005B30EE /* Build configuration list for PBXNativeTarget "another.im" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7E995F112CEAC4BA005B30EE /* Debug */, + 7E995F122CEAC4BA005B30EE /* Adhoc */, + 7E995F132CEAC4BA005B30EE /* Alpha */, + 7E995F142CEAC4BA005B30EE /* AppStore */, + 7E995F152CEAC4BA005B30EE /* AppStore-Quicksy */, + 7E995F162CEAC4BA005B30EE /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C01FCF4E08A954540054247B /* Build configuration list for PBXProject "Monal" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C01FCF4F08A954540054247B /* Debug */, + 26E74FBD17B06D2200FD91AE /* Adhoc */, + C1E1BCAE288BBAB00046AB47 /* Alpha */, + 2675EF5918B98C2D0059C5C3 /* AppStore */, + C15D0B0D2B3EF70E00845061 /* AppStore-Quicksy */, + C11C870B26A83C1D00B8DEA5 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C104918E261301530054AC9E /* Build configuration list for PBXNativeTarget "MonalXMPPUnitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C104918F261301530054AC9E /* Debug */, + C1049190261301530054AC9E /* Adhoc */, + C1E1BCB4288BBAB00046AB47 /* Alpha */, + C1049191261301530054AC9E /* AppStore */, + C15D0B132B3EF70E00845061 /* AppStore-Quicksy */, + C11C871226A83C1D00B8DEA5 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C1850EBC25F38A2D003D506A /* Build configuration list for PBXNativeTarget "MonalUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C1850EBD25F38A2D003D506A /* Debug */, + C1850EBE25F38A2D003D506A /* Adhoc */, + C1E1BCB3288BBAB00046AB47 /* Alpha */, + C1850EBF25F38A2D003D506A /* AppStore */, + C15D0B122B3EF70E00845061 /* AppStore-Quicksy */, + C11C871126A83C1D00B8DEA5 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 841898A82957712000FEC77D /* XCRemoteSwiftPackageReference "ViewExtractor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/GeorgeElsham/ViewExtractor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; + 8418B5652C87E0ED006FAF60 /* XCRemoteSwiftPackageReference "Chat" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/exyte/Chat"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.5; + }; + }; + 849ADF3D2BACF0360009BCD7 /* XCRemoteSwiftPackageReference "cocoalumberjack" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/cocoalumberjack/cocoalumberjack"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.8.5; + }; + }; + 84E231F12C16A9CE00735FB7 /* XCRemoteSwiftPackageReference "SVGView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/exyte/SVGView"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.6; + }; + }; + 84F194CF2C15197200F0A994 /* XCRemoteSwiftPackageReference "FrameUp" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ryanlintott/FrameUp"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.2; + }; + }; + C1F5C7AD2777638B0001F295 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.3; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 8414ADFF2A7ABC4300EFFCCC /* LibMonalRustSwiftBridge */ = { + isa = XCSwiftPackageProductDependency; + productName = LibMonalRustSwiftBridge; + }; + 841898A92957712000FEC77D /* ViewExtractor */ = { + isa = XCSwiftPackageProductDependency; + package = 841898A82957712000FEC77D /* XCRemoteSwiftPackageReference "ViewExtractor" */; + productName = ViewExtractor; + }; + 8418B5662C87E0ED006FAF60 /* ExyteChat */ = { + isa = XCSwiftPackageProductDependency; + package = 8418B5652C87E0ED006FAF60 /* XCRemoteSwiftPackageReference "Chat" */; + productName = ExyteChat; + }; + 849ADF3E2BACF0360009BCD7 /* CocoaLumberjack */ = { + isa = XCSwiftPackageProductDependency; + package = 849ADF3D2BACF0360009BCD7 /* XCRemoteSwiftPackageReference "cocoalumberjack" */; + productName = CocoaLumberjack; + }; + 849ADF402BACF0360009BCD7 /* CocoaLumberjackSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 849ADF3D2BACF0360009BCD7 /* XCRemoteSwiftPackageReference "cocoalumberjack" */; + productName = CocoaLumberjackSwift; + }; + 849ADF422BACF0360009BCD7 /* CocoaLumberjackSwiftLogBackend */ = { + isa = XCSwiftPackageProductDependency; + package = 849ADF3D2BACF0360009BCD7 /* XCRemoteSwiftPackageReference "cocoalumberjack" */; + productName = CocoaLumberjackSwiftLogBackend; + }; + 84E231F22C16A9CE00735FB7 /* SVGView */ = { + isa = XCSwiftPackageProductDependency; + package = 84E231F12C16A9CE00735FB7 /* XCRemoteSwiftPackageReference "SVGView" */; + productName = SVGView; + }; + 84F194D02C15197200F0A994 /* FrameUp */ = { + isa = XCSwiftPackageProductDependency; + package = 84F194CF2C15197200F0A994 /* XCRemoteSwiftPackageReference "FrameUp" */; + productName = FrameUp; + }; + C1F5C7AE2777638B0001F295 /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = C1F5C7AD2777638B0001F295 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 29B97313FDCFA39411CA2CEA /* Project object */; +} diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcbaselines/C1850EB425F38A2D003D506A.xcbaseline/4DCBD219-4D62-4FD1-884E-3C6B2A326087.plist b/Monal/Monal.xcodeproj/xcshareddata/xcbaselines/C1850EB425F38A2D003D506A.xcbaseline/4DCBD219-4D62-4FD1-884E-3C6B2A326087.plist new file mode 100644 index 0000000..b30436e --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcbaselines/C1850EB425F38A2D003D506A.xcbaseline/4DCBD219-4D62-4FD1-884E-3C6B2A326087.plist @@ -0,0 +1,22 @@ + + + + + classNames + + MonalUITests + + testLaunchPerformance() + + com.apple.dt.XCTMetric_ApplicationLaunch-AppLaunch.duration + + baselineAverage + 3 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcbaselines/C1850EB425F38A2D003D506A.xcbaseline/Info.plist b/Monal/Monal.xcodeproj/xcshareddata/xcbaselines/C1850EB425F38A2D003D506A.xcbaseline/Info.plist new file mode 100644 index 0000000..cad1691 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcbaselines/C1850EB425F38A2D003D506A.xcbaseline/Info.plist @@ -0,0 +1,40 @@ + + + + + runDestinationsByUUID + + 4DCBD219-4D62-4FD1-884E-3C6B2A326087 + + localComputer + + busSpeedInMHz + 0 + cpuCount + 1 + cpuKind + Apple M1 + cpuSpeedInMHz + 0 + logicalCPUCoresPerPackage + 8 + modelCode + MacBookPro17,1 + physicalCPUCoresPerPackage + 8 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + arm64 + targetDevice + + modelCode + iPad11,7 + platformIdentifier + com.apple.platform.iphonesimulator + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Alpha.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Alpha.xcscheme new file mode 100644 index 0000000..2ba3b11 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Alpha.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Beta.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Beta.xcscheme new file mode 100644 index 0000000..8366c01 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Beta.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Tests.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Tests.xcscheme new file mode 100644 index 0000000..104c723 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal Tests.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal.xcscheme new file mode 100644 index 0000000..ef87982 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Monal.xcscheme @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/NotificaionService.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/NotificaionService.xcscheme new file mode 100644 index 0000000..4807841 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/NotificaionService.xcscheme @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Quicksy.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Quicksy.xcscheme new file mode 100644 index 0000000..7587035 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/Quicksy.xcscheme @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/another.im.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/another.im.xcscheme new file mode 100644 index 0000000..6df999b --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/another.im.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/monalxmpp.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/monalxmpp.xcscheme new file mode 100644 index 0000000..c7c0e48 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/monalxmpp.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/xcshareddata/xcschemes/shareSheet.xcscheme b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/shareSheet.xcscheme new file mode 100644 index 0000000..f89e8f8 --- /dev/null +++ b/Monal/Monal.xcodeproj/xcshareddata/xcschemes/shareSheet.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/MonalSourceCodePrefix.pch b/Monal/MonalSourceCodePrefix.pch new file mode 100644 index 0000000..412697b --- /dev/null +++ b/Monal/MonalSourceCodePrefix.pch @@ -0,0 +1,4 @@ +// +// Prefix header for all source files of the 'SworIM' target in the 'SworIM' project +// + diff --git a/Monal/MonalUITests/Info.plist b/Monal/MonalUITests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/Monal/MonalUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Monal/MonalUITests/MonalUITests.swift b/Monal/MonalUITests/MonalUITests.swift new file mode 100644 index 0000000..3c35d5c --- /dev/null +++ b/Monal/MonalUITests/MonalUITests.swift @@ -0,0 +1,201 @@ +// +// MonalUITests.swift +// MonalUITests +// +// Created by Friedrich Altheide on 06.03.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +import XCTest + +class MonalUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + private func intro(app: XCUIApplication) + { + // wait for launch + sleep(1) + + let elementsQuery = app.scrollViews["intro_scroll"].otherElements + elementsQuery.buttons["Welcome to Monal, Chat for free with your friends, colleagues and family!"].swipeLeft() + sleep(1) + elementsQuery.buttons["Choices Galore, Use your existing account or make a new one on the many servers around the world"].swipeLeft() + sleep(1) + elementsQuery.buttons["Escape The Garden, You are not trapped in a garden. Talk to anyone else without anyone tracking you."].swipeLeft() + sleep(1) + elementsQuery.buttons["Spread The Word, If you like Monal, please let others know and leave a review"].swipeLeft() + sleep(1) + } + + private func introSkip(app: XCUIApplication) + { + // wait for launch + sleep(1) + app.buttons["Skip"].tap() + sleep(1) + } + + private func createStartArgs() -> [String] + { + return createStartArgs(extraArgs: []) + } + + private func createStartArgs(extraArgs: [String]) -> [String] + { + var startArgs : [String] = ["--disableAnimations"] + // append extraArgs + startArgs.append(contentsOf: extraArgs) + + return startArgs + } + + private func sendMsg(txt: String) + { + let app = XCUIApplication() + sleep(5) + XCTAssertTrue(app.buttons["microphone"].exists) + XCTAssertFalse(app.buttons["Send"].exists) + + app.textViews["NewChatMessageTextField"].tap() + app.textViews["NewChatMessageTextField"].typeText(txt) + // send button should appeared + XCTAssertTrue(app.buttons["send"].exists) + XCTAssertFalse(app.buttons["microphone"].exists) + + app.buttons["send"].tap() + // wait for sending on slow systems + sleep(5) + // send button should be hidden + XCTAssertFalse(app.buttons["send"].exists) + XCTAssertTrue(app.buttons["microphone"].exists) + } + + func test_0001_DBInit() throws { + let app = XCUIApplication() + app.launchArguments = createStartArgs(extraArgs: ["--reset"]) + app.launch() + } + + func test_0002_Intro() throws + { + let app = XCUIApplication() + app.launchArguments = createStartArgs(extraArgs: ["--reset"]) + app.launch() + + intro(app: app) + + let elementsQuery2 = app.scrollViews.otherElements + elementsQuery2.textFields["Account@something.com"].tap() + elementsQuery2.secureTextFields["Password"].tap() + } + + func test_0003_IntroSkip() throws + { + let app = XCUIApplication() + app.launchArguments = createStartArgs(extraArgs: ["--reset"]) + app.launch() + + introSkip(app: app) + app.scrollViews.otherElements.buttons["Set up an account later"].tap() + + let chatsNavigationBar = app.navigationBars["Chats"] + chatsNavigationBar.buttons["Add"].tap() + + let closeButton = app.alerts["No enabled account found"].scrollViews.otherElements.buttons["Close"] + closeButton.tap() + chatsNavigationBar.buttons["Compose"].tap() + closeButton.tap() + } + + func test_0005_Register() throws + { + let app = XCUIApplication() + app.launchArguments = createStartArgs(extraArgs: ["--reset"]) + app.launch() + + introSkip(app: app) + + let elementsQuery = app.scrollViews.otherElements + let registerStaticText = elementsQuery.buttons["Register"] + registerStaticText.tap() + + app.scrollViews.otherElements.buttons["Terms of service"].tap() + // wait for safari window to open + sleep(5) + app.buttons["Done"].tap() + elementsQuery.textFields["Username"].tap() + // create random username + elementsQuery.textFields["Username"].typeText(String(format: "MonalTestclient-%d", Int.random(in: 1000..<999999))) + + elementsQuery.secureTextFields["Password"].tap() + elementsQuery.secureTextFields["Password"].typeText(randomPassword()) + registerStaticText.tap() + // wait for register hud + sleep(10) + let startChattingStaticText = app.buttons["Start Chatting"] + startChattingStaticText.tap() + sleep(1) + app.navigationBars["Privacy Settings"].buttons["Close"].tap() + startChattingStaticText.tap() + } + + func test_0007_PlusAndContactsButtons() throws { + let app = XCUIApplication() + app.launchArguments = createStartArgs() + app.launch() + + let chatsNavigationBar = app.navigationBars["Chats"] + sleep(1) + chatsNavigationBar.buttons["Add"].tap() + + let tablesQuery = app.tables + tablesQuery.staticTexts["Add a New Contact"].tap() + app.navigationBars["Add Contact"].buttons["New"].tap() + tablesQuery.staticTexts["Join a Group Chat"].tap() + app.navigationBars["Join Group Chat"].buttons["New"].tap() + tablesQuery.staticTexts["View Contact Requests"].tap() + app.navigationBars["Contact Requests"].buttons["New"].tap() + app.navigationBars["New"].buttons["Close"].tap() + chatsNavigationBar.buttons["Compose"].tap() + + let contactsNavigationBar = app.navigationBars["Contacts"] + contactsNavigationBar.buttons["Close"].tap() + } + + func test_0008_AddContact() throws { + let app = XCUIApplication() + app.launchArguments = createStartArgs() + app.launch() + + app.navigationBars["Chats"].buttons["Add"].tap() + + let tablesQuery = app.tables + tablesQuery.staticTexts["Add a New Contact"].tap() + tablesQuery.textFields["Contact Jid"].tap() + tablesQuery.textFields["Contact Jid"].typeText("echo@jabber.fu-berlin.de") + + tablesQuery.staticTexts["Add Contact"].tap() + app.alerts["Permission Requested"].scrollViews.otherElements.buttons["Close"].tap() + // wait for segue to chatView + sleep(10) + XCTAssertFalse(app.buttons["send"].exists) + app.textViews["NewChatMessageTextField"].tap() + + sendMsg(txt: "ping") + sendMsg(txt: randomString(length: 100)) + sendMsg(txt: randomString(length: 1000)) + sendMsg(txt: randomString(length: 2000)) + } +} diff --git a/Monal/MonalUITests/TestHelper.swift b/Monal/MonalUITests/TestHelper.swift new file mode 100644 index 0000000..6c6a455 --- /dev/null +++ b/Monal/MonalUITests/TestHelper.swift @@ -0,0 +1,28 @@ +// +// TestHelper.swift +// MonalUITests +// +// Created by Friedrich Altheide on 06.03.21. +// Copyright © 2021 Monal.im. All rights reserved. +// + +import Foundation + +func randomPassword() -> String +{ + let passwordLen = Int.random(in: 20..<100) + return randomString(length: passwordLen) +} + +func randomString(length: Int = 100) -> String +{ + let alphabet: NSString = "qwertzuiopasdfghjklyxcvbnmQWERTZUIOPASDFGHJKLYXCVBNM1234567890!§$%&/()=?,.-;:_*'^" + var password: String = "" + for _ in 0 ..< length + { + var charElement = alphabet.character(at: Int(arc4random_uniform(UInt32(alphabet.length)))) + password += NSString(characters: &charElement, length: 1) as String + } + + return password +} diff --git a/Monal/MonalXMPPUnitTests/AESGCMTest.m b/Monal/MonalXMPPUnitTests/AESGCMTest.m new file mode 100644 index 0000000..7de49d7 --- /dev/null +++ b/Monal/MonalXMPPUnitTests/AESGCMTest.m @@ -0,0 +1,99 @@ +// +// AESGCMTest.m +// Monal Tests +// +// Created by Anurodh Pokharel on 1/7/20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import +#import "AESGcm.h" + +@interface AESGCMTest : XCTestCase + +@end + +@implementation AESGCMTest + +- (void)setUp { + // Put setup code here. This method is called before the invocation of each test method in the class. +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the class. +} + +-(void) encryptWithSize:(int) size +{ + NSString* plaintext = @"ABCDEFGHIKLMOPQRSTUVWXYZ1234567890!\"§$%&/()=?*+#-.,;:_"; + NSData* plaintextUTF8 = [plaintext dataUsingEncoding:NSUTF8StringEncoding]; + MLEncryptedPayload* payload = [AESGcm encrypt:plaintextUTF8 keySize:size]; + + NSData* key = [payload.key subdataWithRange:NSMakeRange(0, size)]; + NSData* auth = [payload.key subdataWithRange:NSMakeRange(size, 16)]; + + NSData* decryptedResult = [AESGcm decrypt:payload.body withKey:key andIv:payload.iv withAuth:auth]; + NSString* decryptedResultString = [[NSString alloc] initWithData:decryptedResult encoding:NSUTF8StringEncoding]; + XCTAssert([decryptedResultString isEqualToString:plaintext]); +} + +-(void) testEncrypt16 +{ + [self encryptWithSize:16]; +} + +-(void) testEncrypt32 +{ + [self encryptWithSize:32]; +} + +/* + * This test doesn't check for real IV uniqueness! + */ +-(void) testGenIV +{ + const UInt32 ivCnt = 40000; + NSMutableArray* ivArray = [[NSMutableArray alloc] init]; + + for(UInt32 i = 0; i < ivCnt; i++) + { + NSData* iv = [AESGcm genIV]; + XCTAssertFalse([ivArray containsObject:iv], "IV should be unique"); + [ivArray addObject:iv]; + } +} + + +/* + * This test doesn't check for real key uniqueness! + */ +-(void) genKeyWithSize:(uint) size +{ + const UInt32 ivCnt = 40000; + NSMutableArray* ivArray = [[NSMutableArray alloc] init]; + + for(UInt32 i = 0; i < ivCnt; i++) + { + NSData* iv = [AESGcm genKey:size]; + XCTAssertFalse([ivArray containsObject:iv], "key should be unique"); + [ivArray addObject:iv]; + } +} + +/* + * This test doesn't check for real key uniqueness! + */ +-(void) testGenKey16 +{ + [self genKeyWithSize:16]; +} + +/* + * This test doesn't check for real key uniqueness! + */ +-(void) testGenKey32 +{ + [self genKeyWithSize:32]; +} + +@end diff --git a/Monal/MonalXMPPUnitTests/Info.plist b/Monal/MonalXMPPUnitTests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/Monal/MonalXMPPUnitTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Monal/MonalXMPPUnitTests/MLCryptoTest.swift b/Monal/MonalXMPPUnitTests/MLCryptoTest.swift new file mode 100644 index 0000000..1f03bd9 --- /dev/null +++ b/Monal/MonalXMPPUnitTests/MLCryptoTest.swift @@ -0,0 +1,65 @@ +// +// MLCryptoTests.swift +// MLCryptoTests +// +// Created by Anurodh Pokharel on 1/7/20. +// Copyright © 2020 Anurodh Pokharel. All rights reserved. +// + +import XCTest +@testable import monalxmpp + +class MLCryptoTests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testEncrypt() { + let crypto = MLCrypto(); + let input = "Monal" + let key = dataWithHexString(hex:"b1eccf9b3afc566e763ba0968e6b5b58"); + let encrypted = crypto.encryptGCM(key: key,decryptedContent: input.data(using: .utf8)!) + + XCTAssert(encrypted != nil) + + let decrypted = crypto.decryptGCM(key:key, encryptedContent:encrypted!.combined!) + let result = String(data: decrypted!, encoding: .utf8) + + XCTAssert(result == input); + } + + func dataWithHexString(hex: String) -> Data { + var hex = hex + var data = Data() + while(hex.count > 0) { + let subIndex = hex.index(hex.startIndex, offsetBy: 2) + let c = String(hex[.. + + + + BGTaskSchedulerPermittedIdentifiers + + im.monal.alpha.process + im.monal.alpha.refresh + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + INStartCallIntent + + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + NotificationService + + + diff --git a/Monal/NotificationService/Alpha.NotificationService.ios.entitlements b/Monal/NotificationService/Alpha.NotificationService.ios.entitlements new file mode 100755 index 0000000..98ec3fb --- /dev/null +++ b/Monal/NotificationService/Alpha.NotificationService.ios.entitlements @@ -0,0 +1,22 @@ + + + + + com.apple.developer.kernel.increased-memory-limit + + com.apple.developer.usernotifications.filtering + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monalalpha + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)monal.alpha + + + diff --git a/Monal/NotificationService/Alpha.NotificationService.macos.entitlements b/Monal/NotificationService/Alpha.NotificationService.macos.entitlements new file mode 100755 index 0000000..5e51a6e --- /dev/null +++ b/Monal/NotificationService/Alpha.NotificationService.macos.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.developer.usernotifications.filtering + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monalalpha + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)monal.alpha + + + diff --git a/Monal/NotificationService/Info.plist b/Monal/NotificationService/Info.plist new file mode 100644 index 0000000..e7b9aed --- /dev/null +++ b/Monal/NotificationService/Info.plist @@ -0,0 +1,44 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + im.monal.process + im.monal.refresh + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + INStartCallIntent + + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + NotificationService + + + diff --git a/Monal/NotificationService/NotificationService.h b/Monal/NotificationService/NotificationService.h new file mode 100644 index 0000000..2db15b9 --- /dev/null +++ b/Monal/NotificationService/NotificationService.h @@ -0,0 +1,13 @@ +// +// NotificationService.h +// NotificationService +// +// Created by Anurodh Pokharel on 9/16/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import + +@interface NotificationService : UNNotificationServiceExtension + +@end diff --git a/Monal/NotificationService/NotificationService.ios.entitlements b/Monal/NotificationService/NotificationService.ios.entitlements new file mode 100755 index 0000000..6526c2a --- /dev/null +++ b/Monal/NotificationService/NotificationService.ios.entitlements @@ -0,0 +1,22 @@ + + + + + com.apple.developer.kernel.increased-memory-limit + + com.apple.developer.usernotifications.filtering + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monal + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)G7YU7X7KRJ.SworIM + + + diff --git a/Monal/NotificationService/NotificationService.m b/Monal/NotificationService/NotificationService.m new file mode 100644 index 0000000..ca337e7 --- /dev/null +++ b/Monal/NotificationService/NotificationService.m @@ -0,0 +1,619 @@ +// +// NotificationService.m +// NotificationService +// +// Created by Anurodh Pokharel on 9/16/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import "NotificationService.h" +#import "MLConstants.h" +#import "HelperTools.h" +#import "IPC.h" +#import "MLProcessLock.h" +#import "MLXMPPManager.h" +#import "MLNotificationManager.h" +#import "MLFiletransfer.h" +#import "xmpp.h" + +@import CallKit; + +@interface NotificationService () ++(BOOL) getAppexCleanShutdownStatus; ++(void) setAppexCleanShutdownStatus:(BOOL) shutdownStatus; +@end + +@interface PushSingleton : NSObject +@property (atomic, strong) NSMutableArray* handlerList; +@property (atomic) BOOL isFirstPush; +@end + +@interface PushHandler : NSObject +@property (atomic, strong) void (^handler)(UNNotificationContent* _Nonnull); +@property (atomic, strong) monal_void_block_t _Nullable expirationTimer; +@end + + +@implementation PushHandler + +-(instancetype) initWithHandler:(void (^)(UNNotificationContent* _Nonnull)) handler andExpirationTimer:(monal_void_block_t) expirationTimer +{ + self = [super init]; + self.handler = handler; + self.expirationTimer = expirationTimer; + return self; +} + +-(void) feed +{ + @synchronized(self) { + if(self.expirationTimer) + self.expirationTimer(); + if(self.handler) + self.handler([UNMutableNotificationContent new]); + self.expirationTimer = nil; + self.handler = nil; + } +} + +-(void) dealloc +{ + @synchronized(self) { + MLAssert(self.expirationTimer == nil && self.handler == nil, @"Deallocating PushHandler while encapsulated timer or handler still active", (@{ + @"expirationTimer": self.expirationTimer == nil ? @"nil" : @"non-nil", + @"handler": self.handler == nil ? @"nil" : @"non-nil", + })); + } +} + +@end + + +@implementation PushSingleton + ++(id) instance +{ + static PushSingleton* sharedInstance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [PushSingleton new]; + }); + return sharedInstance; +} + +-(instancetype) init +{ + self = [super init]; + DDLogInfo(@"Initializing push singleton"); + self.handlerList = [NSMutableArray new]; + self.isFirstPush = YES; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(incomingIPC:) name:kMonalIncomingIPC object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateUnread) name:kMonalUpdateUnread object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(nowIdle:) name:kMonalIdle object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIncomingVoipCall:) name:kMonalIncomingVoipCall object:nil]; + return self; +} + +-(void) dealloc +{ + DDLogError(@"Deallocating push singleton"); + [DDLog flushLog]; +} + +-(BOOL) checkAndUpdateFirstPush:(BOOL) value +{ + BOOL retval; + @synchronized(self) { + retval = self.isFirstPush; + self.isFirstPush = value; + } + return retval; +} + +-(BOOL) checkForNewPushes +{ + @synchronized(self.handlerList) { + return self.handlerList.count > 0; + } +} + +-(BOOL) checkForLastHandler +{ + @synchronized(self.handlerList) { + return self.handlerList.count <= 1; + } +} + +-(void) killAppex +{ + //notify about pending app freeze (don't queue this notification because it should be handled IMMEDIATELY and INLINE) + DDLogVerbose(@"Posting kMonalWillBeFreezed notification now..."); + [[NSNotificationCenter defaultCenter] postNotificationName:kMonalWillBeFreezed object:nil]; + + [NotificationService setAppexCleanShutdownStatus:YES]; + + DDLogInfo(@"Now killing appex process, goodbye..."); + [HelperTools flushLogsWithTimeout:0.100]; + exit(0); +} + +-(BOOL) feedNextHandler +{ + PushHandler* entry = nil; + @synchronized(self.handlerList) { + //return NO if there isn't a single handler left in our list + if(self.handlerList.count == 0) + return NO; + + entry = [self.handlerList firstObject]; + [self.handlerList removeObject:entry]; + } + + //cancel expiration timer if still running and feed our handler with empty content to silence it + DDLogDebug(@"Feeding next handler"); + [entry feed]; + + //return NO if this was the last handler and YES if not + return [self checkForLastHandler]; +} + +-(void) handleIncomingVoipCall:(NSNotification*) notification +{ + DDLogInfo(@"Got incoming VOIP call"); + if([HelperTools shouldProvideVoip]) + { + //disconnect while still being in the receive queue to make sure we don't process any other stanza after this jmi one + //(we don't want to handle a second jmi stanza for example: that could confuse tie-breaking and other parts of our call handling) + xmpp* account = [[MLXMPPManager sharedInstance] getEnabledAccountForID:notification.userInfo[@"accountID"]]; + [account disconnect]; + + //now disconnect all other accounts, post the voip push and kill the appex + //do this in an extra thread to avoid deadlocks via: receive_queue -> disconnect_thread -> receive_queue + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + //directly disconnect without handling any possibly queued stanzas (they will be handled in mainapp once we wake it up) + [self disconnectAndFeedAllWaitingHandlers]; + + DDLogInfo(@"Dispatching voip call to mainapp..."); + NSString* payload = [HelperTools encodeBase64WithData:[HelperTools serializeObject:notification.userInfo]]; + [CXProvider reportNewIncomingVoIPPushPayload:@{@"base64Payload": payload} completion:^(NSError* _Nullable error) { + if(error != nil) + DDLogError(@"Got error for reportNewIncomingVoIPPushPayload: %@", error); + else + DDLogInfo(@"Successfully called reportNewIncomingVoIPPushPayload"); + [self killAppex]; + }]; + }); + } + else + DDLogError(@"shouldProvideVoip returned NO, ignoring incoming call!"); +} + +-(void) disconnectAndFeedAllWaitingHandlers +{ + DDLogInfo(@"Disconnecting all accounts and feeding all pending handlers: %lu", [self.handlerList count]); + + //this has to be synchronous because we only want to continue if all accounts are completely disconnected + [[MLXMPPManager sharedInstance] disconnectAll]; + + //we posted all notifications and disconnected, technically we're not running anymore + //(even though our containing process will still be running for a few more seconds) + [MLProcessLock unlock]; + + //feed all waiting handlers with empty notifications to silence them + //this will terminate/freeze the app extension afterwards + while([self feedNextHandler]) + ; +} + +-(void) incomingPush:(void (^)(UNNotificationContent* _Nullable)) contentHandler +{ + //we set the contentHandler to nil if the push was alreay handled but we want to retrigger the first push logic in here + if(contentHandler) + { + DDLogInfo(@"Got incoming push"); + PushHandler* handler = [[PushHandler alloc] initWithHandler:contentHandler andExpirationTimer:createTimer(25.0, ^{ [self pushExpired]; })]; + @synchronized(self.handlerList) { + [self.handlerList addObject:handler]; + } + } + else + //use warn loglevel to make this rare circumstance more visible in (udp) log + DDLogWarn(@"Got a new push while disconnecting, handling it as if it were the first push"); //see [self pushExpired] for explanation + + //first incoming push? --> ping mainapp + //all pushes not being the first one should do nothing (despite extending our runtime) + if([self checkAndUpdateFirstPush:NO]) + { + DDLogInfo(@"First push, pinging main app"); + if([MLProcessLock checkRemoteRunning:@"MainApp"]) + { + //this will make sure we still run if we get triggered immediately after the mainapp disconnected but before its process got freezed + DDLogDebug(@"Main app already in foreground, sleeping for 5 seconds and trying again"); + usleep(5000000); + DDLogDebug(@"Pinging main app again"); + if([MLProcessLock checkRemoteRunning:@"MainApp"]) + { + DDLogInfo(@"NOT connecting accounts, main app already running in foreground, terminating immediately instead"); + [DDLog flushLog]; + [self disconnectAndFeedAllWaitingHandlers]; + [self killAppex]; + } + else + DDLogDebug(@"Main app not in foreground anymore, handling first push now"); + } + + DDLogDebug(@"locking process and connecting accounts"); + [DDLog flushLog]; + [MLProcessLock lock]; + + //handle message notifications by initializing the MLNotificationManager + [MLNotificationManager sharedInstance]; + + //initialize the xmpp manager (used for connectivity checks etc.) + //we initialize it here to make sure the connectivity check is complete when using it later + [MLXMPPManager sharedInstance]; + usleep(100000); //wait for initial connectivity check (100ms) + + //now connect all enabled accounts + [[MLXMPPManager sharedInstance] connectIfNecessary]; + + //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 + [HelperTools removePendingSyncErrorNotifications]; + } +} + +-(void) pushExpired +{ + DDLogInfo(@"Handling expired push: %lu", (unsigned long)[self.handlerList count]); + + BOOL isLastHandler = [self checkForLastHandler]; + if(isLastHandler) + { + DDLogInfo(@"This was the last handler, freezing all parse queues and posting sync errors..."); + + //we have to freeze all incoming streams until we know if this handler feeding leads to the termination of our appex or not + //we MUST do this before feeding the last handler because after feeding the last one apple does not allow us to + //post any new notifications --> not freezing would lead to lost notifications + [self freezeAllParseQueues]; + + //post sync errors for all accounts still not idle now (e.g. have stanzas in our freezed pase queue or stanzas waiting for smacks acks etc.) + //we MUST do this here because apple des not allow us to post any new notifications after feeding the last handler + [HelperTools updateSyncErrorsWithDeleteOnly:NO andWaitForCompletion:YES]; + } + + //after this we (potentially) can not post any new notifications until the next push comes in (if it comes in at all) + [self feedNextHandler]; + + //check if this was the last handler (ignore if we got a new one in between our call to checkForLastHandler and feedNextHandler, this case will be handled below anyways) + if(isLastHandler) + { + DDLogInfo(@"Last push expired shutting down in 500ms if no new push comes in in the meantime"); + //wait 500ms to allow other pushed already queued on the device (but not yet delivered to us) to be delivered to us + //after the last push expired we have ~5 seconds run time left to do the clean disconnect + //--> waiting 500ms before checking if this was the last push that expired (e.g. no new push came in) does not do any harm here + //WARNING: we have to closely watch apple...if they remove this 5 second gap between this call to the expiration handler and the actual + //appex freeze, this sleep will no longer be harmless and could even cause smacks state corruption (by not diconnecting cleanly and having stanzas + //still in the TCP queue delivered on next appex unfreeze even if they have been handled by the mainapp already) + //NOTE: not sure if that really can happen since we use file based locking, because iOS will kill processes holding such a lock + //when trying to freeze the process (and we would still hold the MLProcessLock for the appex when the freeze happens --> process kill) + usleep(500000); + + //this returns YES if we got new pushes in the meantime --> do nothing if so + if(![self checkForNewPushes]) + { + DDLogInfo(@"Shutting down appex now"); + + //don't post sync errors here, already did so above (see explanation there) + + //schedule a new BGProcessingTaskRequest to process this further as soon as possible, if we are not idle + [HelperTools scheduleBackgroundTask:![[MLXMPPManager sharedInstance] allAccountsIdle]]; + + //this was the last push in the pipeline --> disconnect to prevent double handling of incoming stanzas + //that could be handled in mainapp and later again in NSE on next NSE wakeup (because still queued in the freezed NSE) + //and kill the appex afterwards to get a clean run next time + [self disconnectAndFeedAllWaitingHandlers]; + + //check if we got a new push in the meantime (e.g. while disconnecting) and kill ourselves if not + //(this returns YES if we got new pushes in the meantime) + if([self checkForNewPushes]) + { + DDLogInfo(@"Okay, not shutting down appex: got a last minute push in the meantime"); + //we got a new push but our firstPush flag was NO for that one --> set self.firstPush to YES and + //do the same things we would do for the (really) first push (e.g. connect our accounts) + //NOTE: because we can only reach this code if at least one push already came in and triggered the expiration timer, the following should never happen + MLAssert(![self checkAndUpdateFirstPush:YES], @"first push was already YES, that should never happen"); + + //retrigger the first push logic + [self incomingPush:nil]; + } + else + [self killAppex]; + } + else + { + DDLogInfo(@"Got next push, not shutting down appex"); + //we can unfreeze our incoming streams because we got another push + [self unfreezeAllParseQueues]; + } + } +} + +-(void) incomingIPC:(NSNotification*) notification +{ + NSDictionary* message = notification.userInfo; + if([message[@"name"] isEqualToString:@"Monal.disconnectAll"]) + { + DDLogInfo(@"Got disconnectAll IPC message"); + [self disconnectAndFeedAllWaitingHandlers]; + [self killAppex]; + } + else if([message[@"name"] isEqualToString:@"Monal.connectIfNecessary"]) + { + DDLogInfo(@"Got connectIfNecessary IPC message --> IGNORING!"); + //(re)connect all accounts + //[[MLXMPPManager sharedInstance] connectIfNecessary]; + } +} + +-(void) freezeAllParseQueues +{ + DDLogInfo(@"Freezing all incoming streams until we know if we are either terminating or got another push"); + dispatch_queue_t queue = dispatch_queue_create("im.monal.freezeAllParseQueues", DISPATCH_QUEUE_CONCURRENT); + for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP) + { + //disconnect to prevent endless loops trying to connect + dispatch_async(queue, ^{ + DDLogVerbose(@"freezing: %@", account); + [account freezeParseQueue]; + DDLogVerbose(@"done freezing: %@", account); + }); + } + dispatch_barrier_sync(queue, ^{ + DDLogVerbose(@"freezeAllParseQueues done (inside barrier)"); + }); + DDLogInfo(@"All parse queues frozen now"); +} + +-(void) unfreezeAllParseQueues +{ + DDLogInfo(@"Unfreezing all incoming streams again, we got another push"); + for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP) + [account unfreezeParseQueue]; + DDLogInfo(@"All parse queues operational again"); +} + +-(void) updateUnread +{ + DDLogVerbose(@"updating app badge via updateUnread"); + UNMutableNotificationContent* content = [UNMutableNotificationContent new]; + + NSNumber* unreadMsgCnt = [[DataLayer sharedInstance] countUnreadMessages]; + NSInteger unread = 0; + if(unreadMsgCnt != nil) + unread = [unreadMsgCnt integerValue]; + DDLogVerbose(@"Raw badge value: %lu", (long)unread); + DDLogDebug(@"Adding badge value: %lu", (long)unread); + content.badge = [NSNumber numberWithInteger:unread]; + + UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"badge_update" content:content trigger:nil]; + NSError* error = [HelperTools postUserNotificationRequest:request]; + if(error) + DDLogError(@"Error posting local badge_update notification: %@", error); + else + DDLogVerbose(@"Unread badge updated successfully"); +} + +-(void) nowIdle:(NSNotification*) notification +{ + DDLogInfo(@"### SOME ACCOUNT CHANGED TO IDLE STATE ###"); + [HelperTools updateSyncErrorsWithDeleteOnly:YES andWaitForCompletion:NO]; +} + +@end + + +static NSMutableArray* handlers;; +static BOOL warnUnclean = NO; + +@implementation NotificationService + ++(void) initialize +{ + [HelperTools initSystem]; + + handlers = [NSMutableArray new]; + + //init IPC + [IPC initializeForProcess:@"NotificationServiceExtension"]; + [MLProcessLock initializeForProcess:@"NotificationServiceExtension"]; + + //log startup + DDLogInfo(@"Notification Service Extension started: %@", [HelperTools appBuildVersionInfoFor:MLVersionTypeLog]); + [DDLog flushLog]; + + warnUnclean = ![NotificationService getAppexCleanShutdownStatus]; + if(warnUnclean) + DDLogError(@"detected unclean appex shutdown!"); + + [[HelperTools defaultsDB] setObject:[NSDate now] forKey:@"lastAppexStart"]; + + //mark this appex as unclean (will be cleared directly before calling exit(0)) + [NotificationService setAppexCleanShutdownStatus:NO]; +} + ++(BOOL) getAppexCleanShutdownStatus +{ + //we use the defaultsDB to avoid write transaction to the main DB which would kill the main app while running in the background + //(use the standardUserDefaults of the appex instead of the shared one exposed by our HelperTools to reduce kills due to locking even further) + NSNumber* wasClean = [[NSUserDefaults standardUserDefaults] objectForKey:@"clean_appex_shutdown"]; + return wasClean == nil || wasClean.boolValue; +} + ++(void) setAppexCleanShutdownStatus:(BOOL) shutdownStatus +{ + //we use the defaultsDB to avoid write transaction to the main DB which would kill the main app while running in the background + //(use the standardUserDefaults of the appex instead of the shared one exposed by our HelperTools to reduce kills due to locking even further) + [[NSUserDefaults standardUserDefaults] setBool:shutdownStatus forKey:@"clean_appex_shutdown"]; + [[NSUserDefaults standardUserDefaults] synchronize]; +} + +-(id) init +{ + DDLogInfo(@"Initializing notification service extension class"); + self = [super init]; + return self; +} + +-(void) dealloc +{ + DDLogInfo(@"Deallocating notification service extension class"); + [DDLog flushLog]; +} + +-(void) didReceiveNotificationRequest:(UNNotificationRequest*) request withContentHandler:(void (^)(UNNotificationContent* _Nonnull)) contentHandler +{ + //make sure to handle complete push and properly proxy it while not racing with expired handlers + @synchronized(handlers) { + DDLogInfo(@"Notification handler called (request id: %@)", request.identifier); + DDLogInfo(@"Push userInfo: %@", request.content.userInfo); + [handlers addObject:contentHandler]; + + //only show this notification once a day at maximum (and if a build number was given in our push) +#ifdef IS_ALPHA + if(request.content.userInfo[@"firstGoodBuildNumber"] != nil) +#else + NSDate* lastAppVersionAlert = [[HelperTools defaultsDB] objectForKey:@"lastAppVersionAlert"]; + if((lastAppVersionAlert == nil || [[NSDate date] timeIntervalSinceDate:lastAppVersionAlert] > 86400) && request.content.userInfo[@"firstGoodBuildNumber"] != nil) +#endif + { + NSDictionary* infoDict = [[NSBundle mainBundle] infoDictionary]; + long buildNumber = ((NSString*)[infoDict objectForKey:@"CFBundleVersion"]).integerValue; + long firstGoodBuildNumber = ((NSNumber*)request.content.userInfo[@"firstGoodBuildNumber"]).integerValue; + BOOL isKnownGoodBuild = NO; + for(NSNumber* allowed in request.content.userInfo[@"knownGoodBuildNumber"]) + if(buildNumber == allowed.integerValue) + isKnownGoodBuild = YES; + DDLogDebug(@"current build number: %ld, firstGoodBuildNumber: %ld, isKnownGoodBuild: %@", buildNumber, firstGoodBuildNumber, bool2str(isKnownGoodBuild)); + if(buildNumber < firstGoodBuildNumber && !isKnownGoodBuild) + { + UNMutableNotificationContent* tooOldContent = [UNMutableNotificationContent new]; + tooOldContent.title = NSLocalizedString(@"Very old app version", @""); + tooOldContent.subtitle = NSLocalizedString(@"Please update!", @""); + tooOldContent.body = NSLocalizedString(@"This app is too old and can contain security bugs as well as suddenly cease operation. Please Upgrade!", @""); + tooOldContent.sound = [UNNotificationSound defaultSound]; + UNNotificationRequest* errorRequest = [UNNotificationRequest requestWithIdentifier:[[NSUUID UUID] UUIDString] content:tooOldContent trigger:nil]; + NSError* error = [HelperTools postUserNotificationRequest:errorRequest]; + if(error) + DDLogError(@"Error posting local app-too-old notification: %@", error); + [[HelperTools defaultsDB] setObject:[NSDate now] forKey:@"lastAppVersionAlert"]; + [[HelperTools defaultsDB] synchronize]; + } + } + + #ifdef DEBUG + if(warnUnclean) + { + UNMutableNotificationContent* errorContent = [UNMutableNotificationContent new]; + errorContent.title = NSLocalizedString(@"Unclean appex shutown", @""); + errorContent.body = NSLocalizedString(@"This should never happen, please contact the developers and provide a logfile!", @""); + errorContent.sound = [UNNotificationSound defaultSound]; + UNNotificationRequest* errorRequest = [UNNotificationRequest requestWithIdentifier:[[NSUUID UUID] UUIDString] content:errorContent trigger:nil]; + NSError* error = [HelperTools postUserNotificationRequest:errorRequest]; + if(error) + DDLogError(@"Error posting local appex unclean shutdown error notification: %@", error); + else + warnUnclean = NO; //try again on error + } + #endif + + //proxy to push singleton + DDLogDebug(@"proxying to incomingPush"); + [DDLog flushLog]; + [[PushSingleton instance] incomingPush:contentHandler]; + DDLogDebug(@"incomingPush proxy completed"); + [DDLog flushLog]; + } +} + +-(void) serviceExtensionTimeWillExpire +{ + @synchronized(handlers) { + DDLogError(@"notification handler expired, that should never happen!"); + +/* + #ifdef DEBUG + UNMutableNotificationContent* errorContent = [UNMutableNotificationContent new]; + errorContent.title = @"Unexpected appex expiration"; + errorContent.body = @"This should never happen, please contact the developers and provide a logfile!"; + errorContent.sound = [UNNotificationSound defaultSound]; + UNNotificationRequest* errorRequest = [UNNotificationRequest requestWithIdentifier:[[NSUUID UUID] UUIDString] content:errorContent trigger:nil]; + NSError* error = [HelperTools postUserNotificationRequest:errorRequest]; + if(error) + DDLogError(@"Error posting local appex expiration error notification: %@", error); + #endif + + //It seems the iOS induced deadlock unlocks itself after this expiration handler got called and even new pushes + //can come in while this handler is still running + //--> we just wait for 1.8 seconds to make sure the unlocking can happen + // (this should be greater than the 1.5 seconds waiting time on last pushes and possibly smaller than 2 seconds, + // cause that could be the time apple will kill us after) + //NOTE: the unlocking of our deadlock will feed this expired handler and no killing should occur + //WARNING: if it's a real deadlock not unlocking itself, apple will kill us nontheless, + // but that's not different to us committing suicide like in the old code commented below + usleep(1800000); +*/ + +#ifdef DEBUG + if([handlers count] > 0) + { + //we don't want two error notifications for the user + [NotificationService setAppexCleanShutdownStatus:YES]; + + //we feed all handlers, these shouldn't be silenced already, because we wouldn't see this expiration + for(void (^_handler)(UNNotificationContent* _Nonnull) in handlers) + { + DDLogError(@"Feeding handler with error notification: %@", _handler); + UNMutableNotificationContent* errorContent = [UNMutableNotificationContent new]; + errorContent.title = NSLocalizedString(@"Unexpected appex expiration", @""); + errorContent.body = NSLocalizedString(@"This should never happen, please contact the developers and provide a logfile!", @""); + errorContent.sound = [UNNotificationSound defaultSound]; + _handler(errorContent); + } + } + else + [NotificationService setAppexCleanShutdownStatus:NO]; +#else + if([handlers count] > 0) + { + //we don't want two error notifications for the user + [NotificationService setAppexCleanShutdownStatus:YES]; + + //we feed all handlers, these shouldn't be silenced already, because we wouldn't see this expiration + for(void (^_handler)(UNNotificationContent* _Nonnull) in handlers) + { + DDLogError(@"Feeding handler with silent notification: %@", _handler); + UNMutableNotificationContent* emptyContent = [UNMutableNotificationContent new]; + _handler(emptyContent); + } + } + else + [NotificationService setAppexCleanShutdownStatus:NO]; +#endif + + DDLogInfo(@"Committing suicide..."); + [DDLog flushLog]; + exit(0); + +/* + //proxy to push singleton + DDLogDebug(@"proxying to pushExpired"); + [DDLog flushLog]; + [[PushSingleton instance] pushExpired]; + DDLogDebug(@"pushExpired proxy completed"); + [DDLog flushLog]; +*/ + } +} + +@end diff --git a/Monal/NotificationService/NotificationService.macos.entitlements b/Monal/NotificationService/NotificationService.macos.entitlements new file mode 100755 index 0000000..5559f5f --- /dev/null +++ b/Monal/NotificationService/NotificationService.macos.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.developer.usernotifications.filtering + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monal + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)org.monal-im.prod.catalyst.monal + + + diff --git a/Monal/NotificationService/Quicksy.NotificationService.ios.entitlements b/Monal/NotificationService/Quicksy.NotificationService.ios.entitlements new file mode 100644 index 0000000..0fc2fc5 --- /dev/null +++ b/Monal/NotificationService/Quicksy.NotificationService.ios.entitlements @@ -0,0 +1,22 @@ + + + + + com.apple.developer.kernel.increased-memory-limit + + com.apple.developer.usernotifications.filtering + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.quicksy + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)org.monal-im.prod.ios.quicksy + + + diff --git a/Monal/NotificationService/Quicksy.NotificationService.macos.entitlements b/Monal/NotificationService/Quicksy.NotificationService.macos.entitlements new file mode 100644 index 0000000..8669478 --- /dev/null +++ b/Monal/NotificationService/Quicksy.NotificationService.macos.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.developer.usernotifications.filtering + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.quicksy + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)org.monal-im.prod.catalyst.quicksy + + + diff --git a/Monal/Podfile b/Monal/Podfile new file mode 100644 index 0000000..dc9e576 --- /dev/null +++ b/Monal/Podfile @@ -0,0 +1,134 @@ +project 'Monal.xcodeproj' +#source 'https://cdn.cocoapods.org/' + +# Uncomment the next line to define a global platform for your project +platform :ios, '14.0' + +# ignore all warnings from all pods +inhibit_all_warnings! + +def signalDeps + pod 'SignalProtocolC', git: 'https://github.com/monal-im/libsignal-protocol-c', branch: 'master' + pod 'SignalProtocolObjC', git: 'https://github.com/monal-im/SignalProtocol-ObjC.git', branch: 'master' +end + +def monal + use_frameworks! + inhibit_all_warnings! + pod 'MBProgressHUD', '~> 1.2.0' + pod 'SDWebImage' + pod 'DZNEmptyDataSet' + pod 'CropViewController' + pod 'NotificationBannerSwift', '~> 3.2.0' + pod 'FLAnimatedImage', '~> 1.0' + pod "PromiseKit" +end + +def monalxmpp + # Uncomment the next line if you're using Swift or would like to use dynamic frameworks + use_frameworks! + inhibit_all_warnings! + pod 'SAMKeychain' + pod 'sqlite3/perf-threadsafe', inhibit_warnings: true + pod 'ASN1Decoder' + #later versions of the webrtc lib trigger the following app review error: + pod 'WebRTC-lib' + #pod 'GoogleWebRTC' + pod 'KSCrash', subspecs:['Recording', 'Reporting/Filters/Sets', 'Reporting/Filters/Tools', 'Reporting/Tools', 'Core'] + signalDeps + pod "PromiseKit" +end + +target 'shareSheet' do + # Uncomment the next line if you're using Swift or would like to use dynamic frameworks + use_frameworks! + inhibit_all_warnings! + pod "PromiseKit" +end + +target 'NotificationService' do + # Uncomment the next line if you're using Swift or would like to use dynamic frameworks + use_frameworks! + inhibit_all_warnings! + pod "PromiseKit" +end + +target 'Monal' do + monal +end + +target 'monalxmpp' do + monalxmpp +end + +target 'MonalUITests' do + monalxmpp + monal +end + +target 'MonalXMPPUnitTests' do + monalxmpp +end + +target 'another.im' do + use_frameworks! + inhibit_all_warnings! + # pod 'ASN1Decoder' +end + +# see https://stackoverflow.com/a/36547646/3528174 +post_install do |installer| + fix_deployment_target(installer) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |configuration| + # see https://stackoverflow.com/a/30038120 + configuration.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)', 'DD_NSLOG_LEVEL=5', 'KSLogger_Level=INFO'] + configuration.build_settings.delete('ARCHS') + if target.name == "TOCropViewController-TOCropViewControllerBundle" + configuration.build_settings['CODE_SIGN_IDENTITY[sdk=macosx*]'] = '-' + end + end + end + + # see https://github.com/CocoaPods/CocoaPods/issues/8891#issuecomment-1249151085 + installer.pods_project.targets.each do |target| + if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle" + target.build_configurations.each do |config| + config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' + end + end + end + + # see https://github.com/CocoaPods/CocoaPods/issues/11553 + installer.pods_project.build_configurations.each do |config| + config.build_settings['DEAD_CODE_STRIPPING'] = 'YES' + end +end + +# see https://github.com/CocoaPods/CocoaPods/issues/7314 +def fix_deployment_target(pod_installer) + if !pod_installer + return + end + puts "Make the pods deployment target version the same as our target" + + project = pod_installer.pods_project + deploymentMap = {} + project.build_configurations.each do |config| + deploymentMap[config.name] = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + end + # p deploymentMap + + project.targets.each do |t| + puts " #{t.name}" + t.build_configurations.each do |config| + oldTarget = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + newTarget = deploymentMap[config.name] + if oldTarget == newTarget + next + end + puts " #{config.name} deployment target: #{oldTarget} => #{newTarget}" + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = newTarget + end + end +end diff --git a/Monal/Podfile.lock b/Monal/Podfile.lock new file mode 100644 index 0000000..d780e69 --- /dev/null +++ b/Monal/Podfile.lock @@ -0,0 +1,140 @@ +PODS: + - ASN1Decoder (1.10.0) + - CropViewController (2.7.4) + - DZNEmptyDataSet (1.8.1) + - FLAnimatedImage (1.0.17) + - KSCrash/Core (1.17.5): + - KSCrash/Reporting/Filters/Basic + - KSCrash/Recording (1.17.5): + - KSCrash/Recording/Tools (= 1.17.5) + - KSCrash/Recording/Tools (1.17.5) + - KSCrash/Reporting/Filters/AppleFmt (1.17.5): + - KSCrash/Recording + - KSCrash/Reporting/Filters/Base + - KSCrash/Reporting/Filters/Base (1.17.5): + - KSCrash/Recording + - KSCrash/Reporting/Filters/Basic (1.17.5): + - KSCrash/Recording + - KSCrash/Reporting/Filters/Base + - KSCrash/Reporting/Filters/GZip (1.17.5): + - KSCrash/Recording + - KSCrash/Reporting/Filters/Base + - KSCrash/Reporting/Filters/JSON (1.17.5): + - KSCrash/Recording + - KSCrash/Reporting/Filters/Base + - KSCrash/Reporting/Filters/Sets (1.17.5): + - KSCrash/Recording + - KSCrash/Reporting/Filters/AppleFmt + - KSCrash/Reporting/Filters/Base + - KSCrash/Reporting/Filters/Basic + - KSCrash/Reporting/Filters/GZip + - KSCrash/Reporting/Filters/JSON + - KSCrash/Reporting/Filters/Stringify + - KSCrash/Reporting/Filters/Stringify (1.17.5): + - KSCrash/Recording + - KSCrash/Reporting/Filters/Base + - KSCrash/Reporting/Filters/Tools (1.17.5): + - KSCrash/Recording + - KSCrash/Reporting/Tools (1.17.5): + - KSCrash/Recording + - MarqueeLabel (4.3.2) + - MBProgressHUD (1.2.0) + - NotificationBannerSwift (3.2.1): + - MarqueeLabel (~> 4.3.0) + - SnapKit (~> 5.6.0) + - PromiseKit (8.1.1): + - PromiseKit/CorePromise (= 8.1.1) + - PromiseKit/Foundation (= 8.1.1) + - PromiseKit/UIKit (= 8.1.1) + - PromiseKit/CorePromise (8.1.1) + - PromiseKit/Foundation (8.1.1): + - PromiseKit/CorePromise + - PromiseKit/UIKit (8.1.1): + - PromiseKit/CorePromise + - SAMKeychain (1.5.3) + - SDWebImage (5.19.7): + - SDWebImage/Core (= 5.19.7) + - SDWebImage/Core (5.19.7) + - SignalProtocolC (2.3.3) + - SignalProtocolObjC (1.1.1): + - SignalProtocolC (~> 2.3.3) + - SnapKit (5.6.0) + - "sqlite3/common (3.46.1+1)" + - "sqlite3/perf-threadsafe (3.46.1+1)": + - sqlite3/common + - WebRTC-lib (128.0.0) + +DEPENDENCIES: + - ASN1Decoder + - CropViewController + - DZNEmptyDataSet + - FLAnimatedImage (~> 1.0) + - KSCrash/Core + - KSCrash/Recording + - KSCrash/Reporting/Filters/Sets + - KSCrash/Reporting/Filters/Tools + - KSCrash/Reporting/Tools + - MBProgressHUD (~> 1.2.0) + - NotificationBannerSwift (~> 3.2.0) + - PromiseKit + - SAMKeychain + - SDWebImage + - SignalProtocolC (from `https://github.com/monal-im/libsignal-protocol-c`, branch `master`) + - SignalProtocolObjC (from `https://github.com/monal-im/SignalProtocol-ObjC.git`, branch `master`) + - sqlite3/perf-threadsafe + - WebRTC-lib + +SPEC REPOS: + trunk: + - ASN1Decoder + - CropViewController + - DZNEmptyDataSet + - FLAnimatedImage + - KSCrash + - MarqueeLabel + - MBProgressHUD + - NotificationBannerSwift + - PromiseKit + - SAMKeychain + - SDWebImage + - SnapKit + - sqlite3 + - WebRTC-lib + +EXTERNAL SOURCES: + SignalProtocolC: + :branch: master + :git: https://github.com/monal-im/libsignal-protocol-c + SignalProtocolObjC: + :branch: master + :git: https://github.com/monal-im/SignalProtocol-ObjC.git + +CHECKOUT OPTIONS: + SignalProtocolC: + :commit: 560504888d9c0ba4be860275d206dcef07e0761e + :git: https://github.com/monal-im/libsignal-protocol-c + SignalProtocolObjC: + :commit: e153e868c6737881a196da17612367ab9ecaae13 + :git: https://github.com/monal-im/SignalProtocol-ObjC.git + +SPEC CHECKSUMS: + ASN1Decoder: 91cb1d781b5a178ea7375b2f1519e2bdaaa4c427 + CropViewController: 3489bbf95a3e11c654382b0bae08ac645cdf1b93 + DZNEmptyDataSet: 9525833b9e68ac21c30253e1d3d7076cc828eaa7 + FLAnimatedImage: bbf914596368867157cc71b38a8ec834b3eeb32b + KSCrash: b104a00c75f4c454590ae8d23cc8d8b003dae900 + MarqueeLabel: 15e524a6762552bb279cb17438b8a94990269fb9 + MBProgressHUD: 3ee5efcc380f6a79a7cc9b363dd669c5e1ae7406 + NotificationBannerSwift: dce54ded532b26e30cd8e7f4d80e124a0f2ba7d1 + PromiseKit: d1be44b474e5acfa16adf007a1f49f104e10fead + SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c + SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 + SignalProtocolC: 8092866e45b663a6bc3e45a8d13bad2571dbf236 + SignalProtocolObjC: 1beb46b1d35733e7ab96a919f88bac20ec771c73 + SnapKit: e01d52ebb8ddbc333eefe2132acf85c8227d9c25 + sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb + WebRTC-lib: f93dfa8d970e9b3b9e75c405f93e9cbba7234b22 + +PODFILE CHECKSUM: 8b7083fb607a95d679d6315c3a35a6d269991d91 + +COCOAPODS: 1.16.2 diff --git a/Monal/PrivacyInfo.xcprivacy b/Monal/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..191f157 --- /dev/null +++ b/Monal/PrivacyInfo.xcprivacy @@ -0,0 +1,30 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + 1C8F.1 + + + + + diff --git a/Monal/Quicksy-Info.plist b/Monal/Quicksy-Info.plist new file mode 100644 index 0000000..3b11330 --- /dev/null +++ b/Monal/Quicksy-Info.plist @@ -0,0 +1,154 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + im.monal.process + im.monal.refresh + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${PRODUCT_NAME} + CFBundleDocumentTypes + + + CFBundleTypeName + XMPP Message + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + im.monal.xmpp + + + + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + xmpp + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + quicksyOpen + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.social-networking + LSApplicationQueriesSchemes + + dbapi-2 + + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + Quicksy allows users to take photos and upload to a conversation + NSLocationUsageDescription + Quicksy uses your location when you send a location message in a conversation. + NSLocationWhenInUseUsageDescription + Quicksy uses your location when you send a location message in a conversation. + NSMicrophoneUsageDescription + Quicksy uses the microphone to transmit your voice in audio messages or calls. + NSPhotoLibraryAddUsageDescription + Quicksy allows users to save photos received in conversations. + NSPhotoLibraryUsageDescription + Quicksy allows users to upload photos to recipients in a conversation + NSContactsUsageDescription + Quicksy uploads your contact list to the quicksy server in regular intervals to automatically list possible contacts, who are already using the app, in your contact list. + NSUserActivityTypes + + INSendMessageIntent + INStartCallIntent + + SBUsesNetwork + + SRResearchDataGeneration + + UIBackgroundModes + + audio + fetch + processing + remote-notification + voip + + UIFileSharingEnabled + + UILaunchStoryboardName + Quicksy Launch Screen + UIMainStoryboardFile + Main + UIPrerenderedIcon + + UIRequiresFullScreen + + UIRequiresPersistentWiFi + + UIStatusBarStyle + UIStatusBarStyleLightContent + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.xml + + UTTypeDescription + XMPP Message + UTTypeIdentifier + im.monal.xmpp + UTTypeTagSpecification + + public.filename-extension + + xmpp + + + + + + diff --git a/Monal/Quicksy.ios.entitlements b/Monal/Quicksy.ios.entitlements new file mode 100644 index 0000000..c61dea0 --- /dev/null +++ b/Monal/Quicksy.ios.entitlements @@ -0,0 +1,36 @@ + + + + + aps-environment + production + com.apple.developer.kernel.increased-memory-limit + + com.apple.developer.usernotifications.communication + + com.apple.developer.usernotifications.filtering + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.quicksy + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.personal-information.location + + com.apple.security.personal-information.photos-library + + keychain-access-groups + + $(AppIdentifierPrefix)org.monal-im.prod.ios.quicksy + + + diff --git a/Monal/Quicksy.macos.entitlements b/Monal/Quicksy.macos.entitlements new file mode 100755 index 0000000..9ef6e66 --- /dev/null +++ b/Monal/Quicksy.macos.entitlements @@ -0,0 +1,32 @@ + + + + + aps-environment + production + com.apple.developer.usernotifications.filtering + + com.apple.developer.usernotifications.communication + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.quicksy + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.personal-information.location + + keychain-access-groups + + $(AppIdentifierPrefix)org.monal-im.prod.catalyst.quicksy + + + diff --git a/Monal/Quicksy.sharesheet.entitlements b/Monal/Quicksy.sharesheet.entitlements new file mode 100644 index 0000000..8d50825 --- /dev/null +++ b/Monal/Quicksy.sharesheet.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.quicksy + + com.apple.security.network.client + + + diff --git a/Monal/TestPlan.xctestplan b/Monal/TestPlan.xctestplan new file mode 100644 index 0000000..44d251a --- /dev/null +++ b/Monal/TestPlan.xctestplan @@ -0,0 +1,34 @@ +{ + "configurations" : [ + { + "id" : "22AC57B3-E817-44FE-B3F9-25137606AFD7", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Monal.xcodeproj", + "identifier" : "C1850EB425F38A2D003D506A", + "name" : "MonalUITests" + } + }, + { + "skippedTests" : [ + "MonalXMPPUnitTests" + ], + "target" : { + "containerPath" : "container:Monal.xcodeproj", + "identifier" : "C1049185261301530054AC9E", + "name" : "MonalXMPPUnitTests" + } + } + ], + "version" : 1 +} diff --git a/Monal/another.im/AnotherIMApp.swift b/Monal/another.im/AnotherIMApp.swift new file mode 100644 index 0000000..fa72b14 --- /dev/null +++ b/Monal/another.im/AnotherIMApp.swift @@ -0,0 +1,18 @@ +// +// another_imApp.swift +// another.im +// +// Created by Wo It on 18.11.24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +import SwiftUI + +@main +struct AnotherIMApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Monal/another.im/Assets.xcassets/AppIcon.appiconset/Contents.json b/Monal/another.im/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e4b468d --- /dev/null +++ b/Monal/another.im/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "aim_logo_2.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/another.im/Assets.xcassets/AppIcon.appiconset/aim_logo_2.png b/Monal/another.im/Assets.xcassets/AppIcon.appiconset/aim_logo_2.png new file mode 100644 index 0000000..3b06520 Binary files /dev/null and b/Monal/another.im/Assets.xcassets/AppIcon.appiconset/aim_logo_2.png differ diff --git a/Monal/another.im/Assets.xcassets/Contents.json b/Monal/another.im/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Monal/another.im/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Monal/another.im/ContentView.swift b/Monal/another.im/ContentView.swift new file mode 100644 index 0000000..c17933b --- /dev/null +++ b/Monal/another.im/ContentView.swift @@ -0,0 +1,25 @@ +// +// ContentView.swift +// another.im +// +// Created by Wo It on 18.11.24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/Monal/localization/Base.lproj/Main.storyboard b/Monal/localization/Base.lproj/Main.storyboard new file mode 100644 index 0000000..98ad612 --- /dev/null +++ b/Monal/localization/Base.lproj/Main.storyboard @@ -0,0 +1,2453 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/localization/Base.lproj/Settings.storyboard b/Monal/localization/Base.lproj/Settings.storyboard new file mode 100644 index 0000000..aa0379e --- /dev/null +++ b/Monal/localization/Base.lproj/Settings.storyboard @@ -0,0 +1,845 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/main.m b/Monal/main.m new file mode 100644 index 0000000..0e5838e --- /dev/null +++ b/Monal/main.m @@ -0,0 +1,54 @@ +// +// main.m +// SworIM +// +// Created by Anurodh Pokharel on 11/16/08. +// Copyright __MyCompanyName__ 2008. All rights reserved. +// + +#import +#import "HelperTools.h" +#import "MonalAppDelegate.h" +#import "DataLayer.h" +#import "MLConstants.h" + +int main(int argc, char *argv[]) { + @autoreleasepool { + [HelperTools initSystem]; + + // check start arguments + // reset sworim and ipc database for UI Tests + if([NSProcessInfo.processInfo.arguments containsObject:@"--reset"]) + { + // reset db + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSURL* containerUrl = [HelperTools getContainerURLForPathComponents:@[]]; + NSArray* dbPaths = @[ + [[containerUrl path] stringByAppendingPathComponent:@"sworim.sqlite"], + [[containerUrl path] stringByAppendingPathComponent:@"sworim.sqlite-shm"], + [[containerUrl path] stringByAppendingPathComponent:@"sworim.sqlite-wal"], + [[containerUrl path] stringByAppendingPathComponent:@"ipc.sqlite"], + [[containerUrl path] stringByAppendingPathComponent:@"ipc.sqlite-shm"], + [[containerUrl path] stringByAppendingPathComponent:@"ipc.sqlite-wal"] + ]; + for(NSString* path in dbPaths) + { + NSError* err; + if([fileManager fileExistsAtPath:path]) + [fileManager removeItemAtPath:path error:&err]; + MLAssert(err == nil, @"Error cleaning up DB!"); + } + + // reset NSUserDefaults + [[NSUserDefaults alloc] removePersistentDomainForName:kAppGroup]; + } + // invalidate account states + if([NSProcessInfo.processInfo.arguments containsObject:@"--disableAnimations"]) + [UIView setAnimationsEnabled:NO]; + // invalidate account states + if([NSProcessInfo.processInfo.arguments containsObject:@"--invalidateAccountStates"]) + [[DataLayer sharedInstance] invalidateAllAccountStates]; + + return UIApplicationMain(argc, argv, nil, NSStringFromClass([MonalAppDelegate class])); + } +} diff --git a/Monal/monalxmpp/Info.plist b/Monal/monalxmpp/Info.plist new file mode 100644 index 0000000..c0701c6 --- /dev/null +++ b/Monal/monalxmpp/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/Monal/monalxmpp/module.modulemap b/Monal/monalxmpp/module.modulemap new file mode 100644 index 0000000..5b748bd --- /dev/null +++ b/Monal/monalxmpp/module.modulemap @@ -0,0 +1,56 @@ +module MLContactPrivate { + header "../Classes/MLContact.h" + export * +} +module DataLayerPrivate { + header "../Classes/DataLayer.h" + export * +} +module xmppPrivate { + header "../Classes/xmpp.h" + export * +} +module MLOMEMOPrivate { + header "../Classes/MLOMEMO.h" + export * +} +module MLSignalStorePrivate { + header "../Classes/MLSignalStore.h" + export * +} +module MLXMPPManagerPrivate { + header "../Classes/MLXMPPManager.h" + export * +} +module MLImageManagerPrivate { + header "../Classes/MLImageManager.h" + export * +} +module MLMucProcessorPrivate { + header "../Classes/MLMucProcessor.h" + export * +} +module MLVoIPProcessorPrivate { + header "../Classes/MLVoIPProcessor.h" + export * +} +module MLCallPrivate { + header "../Classes/MLCall.h" + export * +} +module HelperToolsPrivate { + header "../Classes/HelperTools.h" + export * +} +module HelperToolsQuicksy_CountryCodesPrivate { + header "../Classes/HelperTools+Quicksy_CountryCodes.h" + export * +} +module MLDelayableTimerPrivate { + header "../Classes/MLDelayableTimer.h" + export * +} +module Quicksy_CountryPrivate { + header "../Classes/Quicksy_Country.h" + export * +} \ No newline at end of file diff --git a/Monal/monalxmpp/monalxmpp.h b/Monal/monalxmpp/monalxmpp.h new file mode 100644 index 0000000..a8965da --- /dev/null +++ b/Monal/monalxmpp/monalxmpp.h @@ -0,0 +1,30 @@ +// +// monalxmpp.h +// monalxmpp +// +// Created by Anurodh Pokharel on 12/10/19. +// Copyright © 2019 Monal.im. All rights reserved. +// + +#import + +//! Project version number for monalxmpp. +FOUNDATION_EXPORT double monalxmppVersionNumber; + +//! Project version string for monalxmpp. +FOUNDATION_EXPORT const unsigned char monalxmppVersionString[]; + +#import "MLContact.h" +#import "DataLayer.h" +#import "xmpp.h" +#import "MLOMEMO.h" +#import "MLSignalStore.h" +#import "MLXMPPManager.h" +#import "MLImageManager.h" +#import "MLMucProcessor.h" +#import "MLVoIPProcessor.h" +#import "MLCall.h" +#import "HelperTools.h" +#import "HelperTools+Quicksy_CountryCodes.h" +#import "MLDelayableTimer.h" +#import "Quicksy_Country.h" diff --git a/Monal/opensource.html b/Monal/opensource.html new file mode 100644 index 0000000..6260f38 --- /dev/null +++ b/Monal/opensource.html @@ -0,0 +1,1790 @@ + + + + Monal

Copyright (c) 2009-2024, The Monal Developers

+ 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 OWNER 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 The Monal Developers

+ +


+ Logo, Empty DataView and Chat-Placeholder Artwork by Ann-Sophie Zwahlen - https://art.of-sophy.ch/
+ All rights reserved.

+ +
+ Parts of the WebRTC Demo by Stasel

+ https://github.com/stasel/WebRTC-iOS
+ https://github.com/stasel/WebRTC-iOS/blob/main/WebRTC-Demo-App/Sources/Services/WebRTCClient.swift
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+ Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+ Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+ Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+ Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+ (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+ Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+ Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+ Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+ Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+ Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives.
+
+ Copyright 2018 Stasel
+
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
+
+ Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

+ +
+ SVGView: SVG parser and renderer written in SwiftUI
+ https://github.com/exyte/SVGView
+ MIT License
+
+ Copyright (c) 2020 exyte <info@exyte.com>
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE.
+
+ +
+ FrameUP: Reframing SwiftUI Views. A collection of tools to help with layout.
+ https://github.com/ryanlintott/FrameUp
+ MIT License
+
+ Copyright (c) 2022 Ryan Lintott
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE.
+
+ +
+ PromiseKit: Promises for Swift & ObjC.
+ https://github.com/mxcl/PromiseKit
+ MIT License
+
+ Copyright 2016-present, Max Howell; mxcl@me.com
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE.
+
+ +
+ HSLuv-C: Human-friendly HSL

+ https://github.com/hsluv/hsluv-c
+ https://www.hsluv.org/
+ Copyright (c) 2015 Alexei Boronine (original idea, JavaScript implementation)
+ Copyright (c) 2015 Roger Tallada (Obj-C implementation)
+ Copyright (c) 2017 Martin Mitas (C implementation, based on Obj-C implementation)

+ + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE.

+ +
+ Macros for metaprogramming
+ ExtendedC

+ + Copyright (C) 2012 Justin Spahr-Summers
+ Released under the MIT license

+
+ MBProgressHud

+ Copyright © 2009-2020 Matej Bukovinski

+ + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE.

+
+ NotificationBanner

+ Copyright (c) 2017-2018 Daltron

+ + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE.

+
+ CocoaLumberjack

+ BSD 3-Clause License

+ + Copyright (c) 2010-2020, Deusty, LLC + 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.

+ + 3. Neither the name of Deusty nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission of Deusty, LLC.

+ + 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.

+
+

+ EARestrictedScrollView

+ Copyright (c) 2015-2019 Evgeny Aleksandrov evgeny@aleksandrov.ws

+ + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+
+

+ MarqueeLabel

+ Copyright (c) 2011-2017 Charles Powell

+ + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+
+ SnapKit

+ Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit

+ + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE.

+
+ SDWebImage

+ Copyright (c) 2009-2020 Olivier Poitrey rs@dailymotion.com + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is furnished + to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE.

+
+ PSTCollectionView

+ Copyright (c) 2012-2013 Peter Steinberger

+ + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is furnished + to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE.

+
+ SAMKeychain

+ Copyright (c) 2010-2016 Sam Soffes, http://soff.es

+ + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions:

+ + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+
+
+ DZNEmptyDataSet

+ Copyright (c) 2016 Ignacio Romero Zurbuchen iromero@dzen.cl

+ + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+
+ SignalProtocolObjC

+ GNU GENERAL PUBLIC LICENSE

+ Version 3, 29 June 2007

+ + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed.

+ + Preamble

+ + The GNU General Public License is a free, copyleft license for + software and other kinds of works.

+ + The licenses for most software and other practical works are designed + to take away your freedom to share and change the works. By contrast, + the GNU General Public License is intended to guarantee your freedom to + share and change all versions of a program--to make sure it remains free + software for all its users. We, the Free Software Foundation, use the + GNU General Public License for most of our software; it applies also to + any other work released this way by its authors. You can apply it to + your programs, too.

+ + When we speak of free software, we are referring to freedom, not + price. Our General Public Licenses are designed to make sure that you + have the freedom to distribute copies of free software (and charge for + them if you wish), that you receive source code or can get it if you + want it, that you can change the software or use pieces of it in new + free programs, and that you know you can do these things.

+ + To protect your rights, we need to prevent others from denying you + these rights or asking you to surrender the rights. Therefore, you have + certain responsibilities if you distribute copies of the software, or if + you modify it: responsibilities to respect the freedom of others.

+ + For example, if you distribute copies of such a program, whether + gratis or for a fee, you must pass on to the recipients the same + freedoms that you received. You must make sure that they, too, receive + or can get the source code. And you must show them these terms so they + know their rights.

+ + Developers that use the GNU GPL protect your rights with two steps: + (1) assert copyright on the software, and (2) offer you this License + giving you legal permission to copy, distribute and/or modify it.

+ + For the developers' and authors' protection, the GPL clearly explains + that there is no warranty for this free software. For both users' and + authors' sake, the GPL requires that modified versions be marked as + changed, so that their problems will not be attributed erroneously to + authors of previous versions.

+ + Some devices are designed to deny users access to install or run + modified versions of the software inside them, although the manufacturer + can do so. This is fundamentally incompatible with the aim of + protecting users' freedom to change the software. The systematic + pattern of such abuse occurs in the area of products for individuals to + use, which is precisely where it is most unacceptable. Therefore, we + have designed this version of the GPL to prohibit the practice for those + products. If such problems arise substantially in other domains, we + stand ready to extend this provision to those domains in future versions + of the GPL, as needed to protect the freedom of users.

+ + Finally, every program is threatened constantly by software patents. + States should not allow patents to restrict development and use of + software on general-purpose computers, but in those that do, we wish to + avoid the special danger that patents applied to a free program could + make it effectively proprietary. To prevent this, the GPL assures that + patents cannot be used to render the program non-free.

+ + The precise terms and conditions for copying, distribution and + modification follow.

+ + TERMS AND CONDITIONS

+ + 0. Definitions.

+ + "This License" refers to version 3 of the GNU General Public License.

+ + "Copyright" also means copyright-like laws that apply to other kinds of + works, such as semiconductor masks.

+ + "The Program" refers to any copyrightable work licensed under this + License. Each licensee is addressed as "you". "Licensees" and + "recipients" may be individuals or organizations.

+ + To "modify" a work means to copy from or adapt all or part of the work + in a fashion requiring copyright permission, other than the making of an + exact copy. The resulting work is called a "modified version" of the + earlier work or a work "based on" the earlier work.

+ + A "covered work" means either the unmodified Program or a work based + on the Program.

+ + To "propagate" a work means to do anything with it that, without + permission, would make you directly or secondarily liable for + infringement under applicable copyright law, except executing it on a + computer or modifying a private copy. Propagation includes copying, + distribution (with or without modification), making available to the + public, and in some countries other activities as well.

+ + To "convey" a work means any kind of propagation that enables other + parties to make or receive copies. Mere interaction with a user through + a computer network, with no transfer of a copy, is not conveying.

+ + An interactive user interface displays "Appropriate Legal Notices" + to the extent that it includes a convenient and prominently visible + feature that (1) displays an appropriate copyright notice, and (2) + tells the user that there is no warranty for the work (except to the + extent that warranties are provided), that licensees may convey the + work under this License, and how to view a copy of this License. If + the interface presents a list of user commands or options, such as a + menu, a prominent item in the list meets this criterion.

+ + 1. Source Code.

+ + The "source code" for a work means the preferred form of the work + for making modifications to it. "Object code" means any non-source + form of a work.

+ + A "Standard Interface" means an interface that either is an official + standard defined by a recognized standards body, or, in the case of + interfaces specified for a particular programming language, one that + is widely used among developers working in that language.

+ + The "System Libraries" of an executable work include anything, other + than the work as a whole, that (a) is included in the normal form of + packaging a Major Component, but which is not part of that Major + Component, and (b) serves only to enable use of the work with that + Major Component, or to implement a Standard Interface for which an + implementation is available to the public in source code form. A + "Major Component", in this context, means a major essential component + (kernel, window system, and so on) of the specific operating system + (if any) on which the executable work runs, or a compiler used to + produce the work, or an object code interpreter used to run it.

+ + The "Corresponding Source" for a work in object code form means all + the source code needed to generate, install, and (for an executable + work) run the object code and to modify the work, including scripts to + control those activities. However, it does not include the work's + System Libraries, or general-purpose tools or generally available free + programs which are used unmodified in performing those activities but + which are not part of the work. For example, Corresponding Source + includes interface definition files associated with source files for + the work, and the source code for shared libraries and dynamically + linked subprograms that the work is specifically designed to require, + such as by intimate data communication or control flow between those + subprograms and other parts of the work.

+ + The Corresponding Source need not include anything that users + can regenerate automatically from other parts of the Corresponding + Source.

+ + The Corresponding Source for a work in source code form is that + same work.

+ + 2. Basic Permissions.

+ + All rights granted under this License are granted for the term of + copyright on the Program, and are irrevocable provided the stated + conditions are met. This License explicitly affirms your unlimited + permission to run the unmodified Program. The output from running a + covered work is covered by this License only if the output, given its + content, constitutes a covered work. This License acknowledges your + rights of fair use or other equivalent, as provided by copyright law.

+ + You may make, run and propagate covered works that you do not + convey, without conditions so long as your license otherwise remains + in force. You may convey covered works to others for the sole purpose + of having them make modifications exclusively for you, or provide you + with facilities for running those works, provided that you comply with + the terms of this License in conveying all material for which you do + not control copyright. Those thus making or running the covered works + for you must do so exclusively on your behalf, under your direction + and control, on terms that prohibit them from making any copies of + your copyrighted material outside their relationship with you.

+ + Conveying under any other circumstances is permitted solely under + the conditions stated below. Sublicensing is not allowed; section 10 + makes it unnecessary.

+ + 3. Protecting Users' Legal Rights From Anti-Circumvention Law.

+ + No covered work shall be deemed part of an effective technological + measure under any applicable law fulfilling obligations under article + 11 of the WIPO copyright treaty adopted on 20 December 1996, or + similar laws prohibiting or restricting circumvention of such + measures.

+ + When you convey a covered work, you waive any legal power to forbid + circumvention of technological measures to the extent such circumvention + is effected by exercising rights under this License with respect to + the covered work, and you disclaim any intention to limit operation or + modification of the work as a means of enforcing, against the work's + users, your or third parties' legal rights to forbid circumvention of + technological measures.

+ + 4. Conveying Verbatim Copies.

+ + You may convey verbatim copies of the Program's source code as you + receive it, in any medium, provided that you conspicuously and + appropriately publish on each copy an appropriate copyright notice; + keep intact all notices stating that this License and any + non-permissive terms added in accord with section 7 apply to the code; + keep intact all notices of the absence of any warranty; and give all + recipients a copy of this License along with the Program.

+ + You may charge any price or no price for each copy that you convey, + and you may offer support or warranty protection for a fee.

+ + 5. Conveying Modified Source Versions.

+ + You may convey a work based on the Program, or the modifications to + produce it from the Program, in the form of source code under the + terms of section 4, provided that you also meet all of these conditions:

+ + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date.

+ + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices".

+ + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it.

+ + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so.

+ + A compilation of a covered work with other separate and independent + works, which are not by their nature extensions of the covered work, + and which are not combined with it such as to form a larger program, + in or on a volume of a storage or distribution medium, is called an + "aggregate" if the compilation and its resulting copyright are not + used to limit the access or legal rights of the compilation's users + beyond what the individual works permit. Inclusion of a covered work + in an aggregate does not cause this License to apply to the other + parts of the aggregate.

+ + 6. Conveying Non-Source Forms.

+ + You may convey a covered work in object code form under the terms + of sections 4 and 5, provided that you also convey the + machine-readable Corresponding Source under the terms of this License, + in one of these ways:

+ + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + ustomarily used for software interchange.

+ + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medum customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge.

+ + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b.

+ + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements.

+ + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d.

+ + A separable portion of the object code, whose source code is excluded + from the Corresponding Source as a System Library, need not be + included in conveying the object code work.

+ + A "User Product" is either (1) a "consumer product", which means any + tangible personal property which is normally used for personal, family, + or household purposes, or (2) anything designed or sold for incorporation + into a dwelling. In determining whether a product is a consumer product, + doubtful cases shall be resolved in favor of coverage. For a particular + product received by a particular user, "normally used" refers to a + typical or common use of that class of product, regardless of the status + of the particular user or of the way in which the particular user + actually uses, or expects or is expected to use, the product. A product + is a consumer product regardless of whether the product has substantial + commercial, industrial or non-consumer uses, unless such uses represent + the only significant mode of use of the product.

+ + "Installation Information" for a User Product means any methods, + procedures, authorization keys, or other information required to install + and execute modified versions of a covered work in that User Product from + a modified version of its Corresponding Source. The information must + suffice to ensure that the continued functioning of the modified object + code is in no case prevented or interfered with solely because + modification has been made.

+ + If you convey an object code work under this section in, or with, or + specifically for use in, a User Product, and the conveying occurs as + part of a transaction in which the right of possession and use of the + User Product is transferred to the recipient in perpetuity or for a + fixed term (regardless of how the transaction is characterized), the + Corresponding Source conveyed under this section must be accompanied + by the Installation Information. But this requirement does not apply + if neither you nor any third party retains the ability to install + modified object code on the User Product (for example, the work has + been installed in ROM).

+ + The requirement to provide Installation Information does not include a + requirement to continue to provide support service, warranty, or updates + for a work that has been modified or installed by the recipient, or for + the User Product in which it has been modified or installed. Access to a + network may be denied when the modification itself materially and + adversely affects the operation of the network or violates the rules and + protocols for communication across the network.

+ + Corresponding Source conveyed, and Installation Information provided, + in accord with this section must be in a format that is publicly + documented (and with an implementation available to the public in + source code form), and must require no special password or key for + unpacking, reading or copying.

+ + 7. Additional Terms.

+ + "Additional permissions" are terms that supplement the terms of this + License by making exceptions from one or more of its conditions. + Additional permissions that are applicable to the entire Program shall + be treated as though they were included in this License, to the extent + that they are valid under applicable law. If additional permissions + apply only to part of the Program, that part may be used separately + under those permissions, but the entire Program remains governed by + this License without regard to the additional permissions.

+ + When you convey a copy of a covered work, you may at your option + remove any additional permissions from that copy, or from any part of + it. (Additional permissions may be written to require their own + removal in certain cases when you modify the work.) You may place + additional permissions on material, added by you to a covered work, + for which you have or can give appropriate copyright permission.

+ + Notwithstanding any other provision of this License, for material you + add to a covered work, you may (if authorized by the copyright holders of + that material) supplement the terms of this License with terms:

+ + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or

+ + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or

+ + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or

+ + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or

+ + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or

+ + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors.

+ + All other non-permissive additional terms are considered "further + restrictions" within the meaning of section 10. If the Program as you + received it, or any part of it, contains a notice stating that it is + governed by this License along with a term that is a further + restriction, you may remove that term. If a license document contains + a further restriction but permits relicensing or conveying under this + License, you may add to a covered work material governed by the terms + of that license document, provided that the further restriction does + not survive such relicensing or conveying.

+ + If you add terms to a covered work in accord with this section, you + must place, in the relevant source files, a statement of the + additional terms that apply to those files, or a notice indicating + where to find the applicable terms.

+ + Additional terms, permissive or non-permissive, may be stated in the + form of a separately written license, or stated as exceptions; + the above requirements apply either way.

+ + 8. Termination.

+ + You may not propagate or modify a covered work except as expressly + provided under this License. Any attempt otherwise to propagate or + modify it is void, and will automatically terminate your rights under + this License (including any patent licenses granted under the third + paragraph of section 11).

+ + However, if you cease all violation of this License, then your + license from a particular copyright holder is reinstated (a) + provisionally, unless and until the copyright holder explicitly and + finally terminates your license, and (b) permanently, if the copyright + holder fails to notify you of the violation by some reasonable means + prior to 60 days after the cessation.

+ + Moreover, your license from a particular copyright holder is + reinstated permanently if the copyright holder notifies you of the + violation by some reasonable means, this is the first time you have + received notice of violation of this License (for any work) from that + copyright holder, and you cure the violation prior to 30 days after + your receipt of the notice.

+ + Termination of your rights under this section does not terminate the + licenses of parties who have received copies or rights from you under + this License. If your rights have been terminated and not permanently + reinstated, you do not qualify to receive new licenses for the same + material under section 10.

+ + 9. Acceptance Not Required for Having Copies.

+ + You are not required to accept this License in order to receive or + run a copy of the Program. Ancillary propagation of a covered work + occurring solely as a consequence of using peer-to-peer transmission + to receive a copy likewise does not require acceptance. However, + nothing other than this License grants you permission to propagate or + modify any covered work. These actions infringe copyright if you do + not accept this License. Therefore, by modifying or propagating a + covered work, you indicate your acceptance of this License to do so.

+ + 10. Automatic Licensing of Downstream Recipients.

+ + Each time you convey a covered work, the recipient automatically + receives a license from the original licensors, to run, modify and + propagate that work, subject to this License. You are not responsible + for enforcing compliance by third parties with this License.

+ + An "entity transaction" is a transaction transferring control of an + organization, or substantially all assets of one, or subdividing an + organization, or merging organizations. If propagation of a covered + work results from an entity transaction, each party to that + transaction who receives a copy of the work also receives whatever + licenses to the work the party's predecessor in interest had or could + give under the previous paragraph, plus a right to possession of the + Corresponding Source of the work from the predecessor in interest, if + the predecessor has it or can get it with reasonable efforts.

+ + You may not impose any further restrictions on the exercise of the + rights granted or affirmed under this License. For example, you may + not impose a license fee, royalty, or other charge for exercise of + rights granted under this License, and you may not initiate litigation + (including a cross-claim or counterclaim in a lawsuit) alleging that + any patent claim is infringed by making, using, selling, offering for + sale, or importing the Program or any portion of it.

+ + 11. Patents.

+ + A "contributor" is a copyright holder who authorizes use under this + License of the Program or a work on which the Program is based. The + work thus licensed is called the contributor's "contributor version".

+ + A contributor's "essential patent claims" are all patent claims + owned or controlled by the contributor, whether already acquired or + hereafter acquired, that would be infringed by some manner, permitted + by this License, of making, using, or selling its contributor version, + but do not include claims that would be infringed only as a + consequence of further modification of the contributor version. For + purposes of this definition, "control" includes the right to grant + patent sublicenses in a manner consistent with the requirements of + this License.

+ + Each contributor grants you a non-exclusive, worldwide, royalty-free + patent license under the contributor's essential patent claims, to + make, use, sell, offer for sale, import and otherwise run, modify and + propagate the contents of its contributor version.

+ + In the following three paragraphs, a "patent license" is any express + agreement or commitment, however denominated, not to enforce a patent + (such as an express permission to practice a patent or covenant not to + sue for patent infringement). To "grant" such a patent license to a + party means to make such an agreement or commitment not to enforce a + patent against the party.

+ + If you convey a covered work, knowingly relying on a patent license, + and the Corresponding Source of the work is not available for anyone + to copy, free of charge and under the terms of this License, through a + publicly available network server or other readily accessible means, + then you must either (1) cause the Corresponding Source to be so + available, or (2) arrange to deprive yourself of the benefit of the + patent license for this particular work, or (3) arrange, in a manner + consistent with the requirements of this License, to extend the patent + license to downstream recipients. "Knowingly relying" means you have + actual knowledge that, but for the patent license, your conveying the + covered work in a country, or your recipient's use of the covered work + in a country, would infringe one or more identifiable patents in that + country that you have reason to believe are valid.

+ + If, pursuant to or in connection with a single transaction or + arrangement, you convey, or propagate by procuring conveyance of, a + covered work, and grant a patent license to some of the parties + receiving the covered work authorizing them to use, propagate, modify + or convey a specific copy of the covered work, then the patent license + you grant is automatically extended to all recipients of the covered + work and works based on it.

+ + A patent license is "discriminatory" if it does not include within + the scope of its coverage, prohibits the exercise of, or is + conditioned on the non-exercise of one or more of the rights that are + specifically granted under this License. You may not convey a covered + work if you are a party to an arrangement with a third party that is + in the business of distributing software, under which you make payment + to the third party based on the extent of your activity of conveying + the work, and under which the third party grants, to any of the + parties who would receive the covered work from you, a discriminatory + patent license (a) in connection with copies of the covered work + conveyed by you (or copies made from those copies), or (b) primarily + for and in connection with specific products or compilations that + contain the covered work, unless you entered into that arrangement, + or that patent license was granted, prior to 28 March 2007.

+ + Nothing in this License shall be construed as excluding or limiting + any implied license or other defenses to infringement that may + otherwise be available to you under applicable patent law.

+ + 12. No Surrender of Others' Freedom.

+ + If conditions are imposed on you (whether by court order, agreement or + otherwise) that contradict the conditions of this License, they do not + excuse you from the conditions of this License. If you cannot convey a + covered work so as to satisfy simultaneously your obligations under this + License and any other pertinent obligations, then as a consequence you may + not convey it at all. For example, if you agree to terms that obligate you + to collect a royalty for further conveying from those to whom you convey + the Program, the only way you could satisfy both those terms and this + License would be to refrain entirely from conveying the Program.

+ + 13. Use with the GNU Affero General Public License.

+ + Notwithstanding any other provision of this License, you have + permission to link or combine any covered work with a work licensed + under version 3 of the GNU Affero General Public License into a single + combined work, and to convey the resulting work. The terms of this + License will continue to apply to the part which is the covered work, + but the special requirements of the GNU Affero General Public License, + section 13, concerning interaction through a network will apply to the + combination as such.

+ + 14. Revised Versions of this License.

+ + The Free Software Foundation may publish revised and/or new versions of + the GNU General Public License from time to time. Such new versions will + be similar in spirit to the present version, but may differ in detail to + address new problems or concerns.

+ + Each version is given a distinguishing version number. If the + Program specifies that a certain numbered version of the GNU General + Public License "or any later version" applies to it, you have the + option of following the terms and conditions either of that numbered + version or of any later version published by the Free Software + Foundation. If the Program does not specify a version number of the + GNU General Public License, you may choose any version ever published + by the Free Software Foundation.

+ + If the Program specifies that a proxy can decide which future + versions of the GNU General Public License can be used, that proxy's + public statement of acceptance of a version permanently authorizes you + to choose that version for the Program.

+ + Later license versions may give you additional or different + permissions. However, no additional obligations are imposed on any + author or copyright holder as a result of your choosing to follow a + later version.

+ + 15. Disclaimer of Warranty.

+ + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF + ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

+ + 16. Limitation of Liability.

+ + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF + SUCH DAMAGES.

+ + 17. Interpretation of Sections 15 and 16.

+ + If the disclaimer of warranty and limitation of liability provided + above cannot be given local legal effect according to their terms, + reviewing courts shall apply local law that most closely approximates + an absolute waiver of all civil liability in connection with the + Program, unless a warranty or assumption of liability accompanies a + copy of the Program in return for a fee.

+ + END OF TERMS AND CONDITIONS

+ + How to Apply These Terms to Your New Programs

+ + If you develop a new program, and you want it to be of the greatest + possible use to the public, the best way to achieve this is to make it + free software which everyone can redistribute and change under these terms.

+ + To do so, attach the following notices to the program. It is safest + to attach them to the start of each source file to most effectively + state the exclusion of warranty; and each file should have at least + the "copyright" line and a pointer to where the full notice is found.

+ + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author}

+ + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version.

+ + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details.

+ + You should have received a copy of the GNU General Public License + along with this program. If not, see .

+ + Also add information on how to contact you by electronic and paper mail.

+ + If the program does terminal interaction, make it output a short + notice like this when it starts in an interactive mode:

+ + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details.

+ + The hypothetical commands `show w' and `show c' should show the appropriate + parts of the General Public License. Of course, your program's commands + might be different; for a GUI interface, you would use an "about box".

+ + You should also get your employer (if you work as a programmer) or school, + if any, to sign a "copyright disclaimer" for the program, if necessary. + For more information on this, and how to apply and follow the GNU GPL, see + .

+ + The GNU General Public License does not permit incorporating your program + into proprietary programs. If your program is a subroutine library, you + may consider it more useful to permit linking proprietary applications with + the library. If this is what you want to do, use the GNU Lesser General + Public License instead of this License. But first, please read + .

+
+ SignalProtocolC

+ GNU GENERAL PUBLIC LICENSE

+ Version 3, 29 June 2007

+ + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed.

+ + Preamble

+ + The GNU General Public License is a free, copyleft license for + software and other kinds of works.

+ + The licenses for most software and other practical works are designed + to take away your freedom to share and change the works. By contrast, + the GNU General Public License is intended to guarantee your freedom to + share and change all versions of a program--to make sure it remains free + software for all its users. We, the Free Software Foundation, use the + GNU General Public License for most of our software; it applies also to + any other work released this way by its authors. You can apply it to + your programs, too.

+ + When we speak of free software, we are referring to freedom, not + price. Our General Public Licenses are designed to make sure that you + have the freedom to distribute copies of free software (and charge for + them if you wish), that you receive source code or can get it if you + want it, that you can change the software or use pieces of it in new + free programs, and that you know you can do these things.

+ + To protect your rights, we need to prevent others from denying you + these rights or asking you to surrender the rights. Therefore, you have + certain responsibilities if you distribute copies of the software, or if + you modify it: responsibilities to respect the freedom of others.

+ + For example, if you distribute copies of such a program, whether + gratis or for a fee, you must pass on to the recipients the same + freedoms that you received. You must make sure that they, too, receive + or can get the source code. And you must show them these terms so they + know their rights.

+ + Developers that use the GNU GPL protect your rights with two steps: + (1) assert copyright on the software, and (2) offer you this License + giving you legal permission to copy, distribute and/or modify it.

+ + For the developers' and authors' protection, the GPL clearly explains + that there is no warranty for this free software. For both users' and + authors' sake, the GPL requires that modified versions be marked as + changed, so that their problems will not be attributed erroneously to + authors of previous versions.

+ + Some devices are designed to deny users access to install or run + modified versions of the software inside them, although the manufacturer + can do so. This is fundamentally incompatible with the aim of + protecting users' freedom to change the software. The systematic + pattern of such abuse occurs in the area of products for individuals to + use, which is precisely where it is most unacceptable. Therefore, we + have designed this version of the GPL to prohibit the practice for those + products. If such problems arise substantially in other domains, we + stand ready to extend this provision to those domains in future versions + of the GPL, as needed to protect the freedom of users.

+ + Finally, every program is threatened constantly by software patents. + States should not allow patents to restrict development and use of + software on general-purpose computers, but in those that do, we wish to + avoid the special danger that patents applied to a free program could + make it effectively proprietary. To prevent this, the GPL assures that + patents cannot be used to render the program non-free.

+ + The precise terms and conditions for copying, distribution and + modification follow.

+ + TERMS AND CONDITIONS

+ + 0. Definitions.

+ + "This License" refers to version 3 of the GNU General Public License.

+ + "Copyright" also means copyright-like laws that apply to other kinds of + works, such as semiconductor masks.

+ + "The Program" refers to any copyrightable work licensed under this + License. Each licensee is addressed as "you". "Licensees" and + "recipients" may be individuals or organizations.

+ + To "modify" a work means to copy from or adapt all or part of the work + in a fashion requiring copyright permission, other than the making of an + exact copy. The resulting work is called a "modified version" of the + earlier work or a work "based on" the earlier work.

+ + A "covered work" means either the unmodified Program or a work based + on the Program.

+ + To "propagate" a work means to do anything with it that, without + permission, would make you directly or secondarily liable for + infringement under applicable copyright law, except executing it on a + computer or modifying a private copy. Propagation includes copying, + distribution (with or without modification), making available to the + public, and in some countries other activities as well.

+ + To "convey" a work means any kind of propagation that enables other + parties to make or receive copies. Mere interaction with a user through + a computer network, with no transfer of a copy, is not conveying.

+ + An interactive user interface displays "Appropriate Legal Notices" + to the extent that it includes a convenient and prominently visible + feature that (1) displays an appropriate copyright notice, and (2) + tells the user that there is no warranty for the work (except to the + extent that warranties are provided), that licensees may convey the + work under this License, and how to view a copy of this License. If + the interface presents a list of user commands or options, such as a + menu, a prominent item in the list meets this criterion.

+ + 1. Source Code.

+ + The "source code" for a work means the preferred form of the work + for making modifications to it. "Object code" means any non-source + form of a work.

+ + A "Standard Interface" means an interface that either is an official + standard defined by a recognized standards body, or, in the case of + interfaces specified for a particular programming language, one that + is widely used among developers working in that language.

+ + The "System Libraries" of an executable work include anything, other + than the work as a whole, that (a) is included in the normal form of + packaging a Major Component, but which is not part of that Major + Component, and (b) serves only to enable use of the work with that + Major Component, or to implement a Standard Interface for which an + implementation is available to the public in source code form. A + "Major Component", in this context, means a major essential component + (kernel, window system, and so on) of the specific operating system + (if any) on which the executable work runs, or a compiler used to + produce the work, or an object code interpreter used to run it.

+ + The "Corresponding Source" for a work in object code form means all + the source code needed to generate, install, and (for an executable + work) run the object code and to modify the work, including scripts to + control those activities. However, it does not include the work's + System Libraries, or general-purpose tools or generally available free + programs which are used unmodified in performing those activities but + which are not part of the work. For example, Corresponding Source + includes interface definition files associated with source files for + the work, and the source code for shared libraries and dynamically + linked subprograms that the work is specifically designed to require, + such as by intimate data communication or control flow between those + subprograms and other parts of the work.

+ + The Corresponding Source need not include anything that users + can regenerate automatically from other parts of the Corresponding + Source.

+ + The Corresponding Source for a work in source code form is that + same work.

+ + 2. Basic Permissions.

+ + All rights granted under this License are granted for the term of + copyright on the Program, and are irrevocable provided the stated + conditions are met. This License explicitly affirms your unlimited + permission to run the unmodified Program. The output from running a + covered work is covered by this License only if the output, given its + content, constitutes a covered work. This License acknowledges your + rights of fair use or other equivalent, as provided by copyright law.

+ + You may make, run and propagate covered works that you do not + convey, without conditions so long as your license otherwise remains + in force. You may convey covered works to others for the sole purpose + of having them make modifications exclusively for you, or provide you + with facilities for running those works, provided that you comply with + the terms of this License in conveying all material for which you do + not control copyright. Those thus making or running the covered works + for you must do so exclusively on your behalf, under your direction + and control, on terms that prohibit them from making any copies of + your copyrighted material outside their relationship with you.

+ + Conveying under any other circumstances is permitted solely under + the conditions stated below. Sublicensing is not allowed; section 10 + makes it unnecessary.

+ + 3. Protecting Users' Legal Rights From Anti-Circumvention Law.

+ + No covered work shall be deemed part of an effective technological + measure under any applicable law fulfilling obligations under article + 11 of the WIPO copyright treaty adopted on 20 December 1996, or + similar laws prohibiting or restricting circumvention of such + measures.

+ + When you convey a covered work, you waive any legal power to forbid + circumvention of technological measures to the extent such circumvention + is effected by exercising rights under this License with respect to + the covered work, and you disclaim any intention to limit operation or + modification of the work as a means of enforcing, against the work's + users, your or third parties' legal rights to forbid circumvention of + technological measures.

+ + 4. Conveying Verbatim Copies.

+ + You may convey verbatim copies of the Program's source code as you + receive it, in any medium, provided that you conspicuously and + appropriately publish on each copy an appropriate copyright notice; + keep intact all notices stating that this License and any + non-permissive terms added in accord with section 7 apply to the code; + keep intact all notices of the absence of any warranty; and give all + recipients a copy of this License along with the Program.

+ + You may charge any price or no price for each copy that you convey, + and you may offer support or warranty protection for a fee.

+ + 5. Conveying Modified Source Versions.

+ + You may convey a work based on the Program, or the modifications to + produce it from the Program, in the form of source code under the + terms of section 4, provided that you also meet all of these conditions:

+ + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date.

+ + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices".

+ + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it.

+ + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so.

+ + A compilation of a covered work with other separate and independent + works, which are not by their nature extensions of the covered work, + and which are not combined with it such as to form a larger program, + in or on a volume of a storage or distribution medium, is called an + "aggregate" if the compilation and its resulting copyright are not + used to limit the access or legal rights of the compilation's users + beyond what the individual works permit. Inclusion of a covered work + in an aggregate does not cause this License to apply to the other + parts of the aggregate.

+ + 6. Conveying Non-Source Forms.

+ + You may convey a covered work in object code form under the terms + of sections 4 and 5, provided that you also convey the + machine-readable Corresponding Source under the terms of this License, + in one of these ways:

+ + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + ustomarily used for software interchange.

+ + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medum customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge.

+ + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b.

+ + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements.

+ + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d.

+ + A separable portion of the object code, whose source code is excluded + from the Corresponding Source as a System Library, need not be + included in conveying the object code work.

+ + A "User Product" is either (1) a "consumer product", which means any + tangible personal property which is normally used for personal, family, + or household purposes, or (2) anything designed or sold for incorporation + into a dwelling. In determining whether a product is a consumer product, + doubtful cases shall be resolved in favor of coverage. For a particular + product received by a particular user, "normally used" refers to a + typical or common use of that class of product, regardless of the status + of the particular user or of the way in which the particular user + actually uses, or expects or is expected to use, the product. A product + is a consumer product regardless of whether the product has substantial + commercial, industrial or non-consumer uses, unless such uses represent + the only significant mode of use of the product.

+ + "Installation Information" for a User Product means any methods, + procedures, authorization keys, or other information required to install + and execute modified versions of a covered work in that User Product from + a modified version of its Corresponding Source. The information must + suffice to ensure that the continued functioning of the modified object + code is in no case prevented or interfered with solely because + modification has been made.

+ + If you convey an object code work under this section in, or with, or + specifically for use in, a User Product, and the conveying occurs as + part of a transaction in which the right of possession and use of the + User Product is transferred to the recipient in perpetuity or for a + fixed term (regardless of how the transaction is characterized), the + Corresponding Source conveyed under this section must be accompanied + by the Installation Information. But this requirement does not apply + if neither you nor any third party retains the ability to install + modified object code on the User Product (for example, the work has + been installed in ROM).

+ + The requirement to provide Installation Information does not include a + requirement to continue to provide support service, warranty, or updates + for a work that has been modified or installed by the recipient, or for + the User Product in which it has been modified or installed. Access to a + network may be denied when the modification itself materially and + adversely affects the operation of the network or violates the rules and + protocols for communication across the network.

+ + Corresponding Source conveyed, and Installation Information provided, + in accord with this section must be in a format that is publicly + documented (and with an implementation available to the public in + source code form), and must require no special password or key for + unpacking, reading or copying.

+ + 7. Additional Terms.

+ + "Additional permissions" are terms that supplement the terms of this + License by making exceptions from one or more of its conditions. + Additional permissions that are applicable to the entire Program shall + be treated as though they were included in this License, to the extent + that they are valid under applicable law. If additional permissions + apply only to part of the Program, that part may be used separately + under those permissions, but the entire Program remains governed by + this License without regard to the additional permissions.

+ + When you convey a copy of a covered work, you may at your option + remove any additional permissions from that copy, or from any part of + it. (Additional permissions may be written to require their own + removal in certain cases when you modify the work.) You may place + additional permissions on material, added by you to a covered work, + for which you have or can give appropriate copyright permission.

+ + Notwithstanding any other provision of this License, for material you + add to a covered work, you may (if authorized by the copyright holders of + that material) supplement the terms of this License with terms:

+ + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or

+ + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or

+ + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or

+ + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or

+ + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or

+ + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors.

+ + All other non-permissive additional terms are considered "further + restrictions" within the meaning of section 10. If the Program as you + received it, or any part of it, contains a notice stating that it is + governed by this License along with a term that is a further + restriction, you may remove that term. If a license document contains + a further restriction but permits relicensing or conveying under this + License, you may add to a covered work material governed by the terms + of that license document, provided that the further restriction does + not survive such relicensing or conveying.

+ + If you add terms to a covered work in accord with this section, you + must place, in the relevant source files, a statement of the + additional terms that apply to those files, or a notice indicating + where to find the applicable terms.

+ + Additional terms, permissive or non-permissive, may be stated in the + form of a separately written license, or stated as exceptions; + the above requirements apply either way.

+ + 8. Termination.

+ + You may not propagate or modify a covered work except as expressly + provided under this License. Any attempt otherwise to propagate or + modify it is void, and will automatically terminate your rights under + this License (including any patent licenses granted under the third + paragraph of section 11).

+ + However, if you cease all violation of this License, then your + license from a particular copyright holder is reinstated (a) + provisionally, unless and until the copyright holder explicitly and + finally terminates your license, and (b) permanently, if the copyright + holder fails to notify you of the violation by some reasonable means + prior to 60 days after the cessation.

+ + Moreover, your license from a particular copyright holder is + reinstated permanently if the copyright holder notifies you of the + violation by some reasonable means, this is the first time you have + received notice of violation of this License (for any work) from that + copyright holder, and you cure the violation prior to 30 days after + your receipt of the notice.

+ + Termination of your rights under this section does not terminate the + licenses of parties who have received copies or rights from you under + this License. If your rights have been terminated and not permanently + reinstated, you do not qualify to receive new licenses for the same + material under section 10.

+ + 9. Acceptance Not Required for Having Copies.

+ + You are not required to accept this License in order to receive or + run a copy of the Program. Ancillary propagation of a covered work + occurring solely as a consequence of using peer-to-peer transmission + to receive a copy likewise does not require acceptance. However, + nothing other than this License grants you permission to propagate or + modify any covered work. These actions infringe copyright if you do + not accept this License. Therefore, by modifying or propagating a + covered work, you indicate your acceptance of this License to do so.

+ + 10. Automatic Licensing of Downstream Recipients.

+ + Each time you convey a covered work, the recipient automatically + receives a license from the original licensors, to run, modify and + propagate that work, subject to this License. You are not responsible + for enforcing compliance by third parties with this License.

+ + An "entity transaction" is a transaction transferring control of an + organization, or substantially all assets of one, or subdividing an + organization, or merging organizations. If propagation of a covered + work results from an entity transaction, each party to that + transaction who receives a copy of the work also receives whatever + licenses to the work the party's predecessor in interest had or could + give under the previous paragraph, plus a right to possession of the + Corresponding Source of the work from the predecessor in interest, if + the predecessor has it or can get it with reasonable efforts.

+ + You may not impose any further restrictions on the exercise of the + rights granted or affirmed under this License. For example, you may + not impose a license fee, royalty, or other charge for exercise of + rights granted under this License, and you may not initiate litigation + (including a cross-claim or counterclaim in a lawsuit) alleging that + any patent claim is infringed by making, using, selling, offering for + sale, or importing the Program or any portion of it.

+ + 11. Patents.

+ + A "contributor" is a copyright holder who authorizes use under this + License of the Program or a work on which the Program is based. The + work thus licensed is called the contributor's "contributor version".

+ + A contributor's "essential patent claims" are all patent claims + owned or controlled by the contributor, whether already acquired or + hereafter acquired, that would be infringed by some manner, permitted + by this License, of making, using, or selling its contributor version, + but do not include claims that would be infringed only as a + consequence of further modification of the contributor version. For + purposes of this definition, "control" includes the right to grant + patent sublicenses in a manner consistent with the requirements of + this License.

+ + Each contributor grants you a non-exclusive, worldwide, royalty-free + patent license under the contributor's essential patent claims, to + make, use, sell, offer for sale, import and otherwise run, modify and + propagate the contents of its contributor version.

+ + In the following three paragraphs, a "patent license" is any express + agreement or commitment, however denominated, not to enforce a patent + (such as an express permission to practice a patent or covenant not to + sue for patent infringement). To "grant" such a patent license to a + party means to make such an agreement or commitment not to enforce a + patent against the party.

+ + If you convey a covered work, knowingly relying on a patent license, + and the Corresponding Source of the work is not available for anyone + to copy, free of charge and under the terms of this License, through a + publicly available network server or other readily accessible means, + then you must either (1) cause the Corresponding Source to be so + available, or (2) arrange to deprive yourself of the benefit of the + patent license for this particular work, or (3) arrange, in a manner + consistent with the requirements of this License, to extend the patent + license to downstream recipients. "Knowingly relying" means you have + actual knowledge that, but for the patent license, your conveying the + covered work in a country, or your recipient's use of the covered work + in a country, would infringe one or more identifiable patents in that + country that you have reason to believe are valid.

+ + If, pursuant to or in connection with a single transaction or + arrangement, you convey, or propagate by procuring conveyance of, a + covered work, and grant a patent license to some of the parties + receiving the covered work authorizing them to use, propagate, modify + or convey a specific copy of the covered work, then the patent license + you grant is automatically extended to all recipients of the covered + work and works based on it.

+ + A patent license is "discriminatory" if it does not include within + the scope of its coverage, prohibits the exercise of, or is + conditioned on the non-exercise of one or more of the rights that are + specifically granted under this License. You may not convey a covered + work if you are a party to an arrangement with a third party that is + in the business of distributing software, under which you make payment + to the third party based on the extent of your activity of conveying + the work, and under which the third party grants, to any of the + parties who would receive the covered work from you, a discriminatory + patent license (a) in connection with copies of the covered work + conveyed by you (or copies made from those copies), or (b) primarily + for and in connection with specific products or compilations that + contain the covered work, unless you entered into that arrangement, + or that patent license was granted, prior to 28 March 2007.

+ + Nothing in this License shall be construed as excluding or limiting + any implied license or other defenses to infringement that may + otherwise be available to you under applicable patent law.

+ + 12. No Surrender of Others' Freedom.

+ + If conditions are imposed on you (whether by court order, agreement or + otherwise) that contradict the conditions of this License, they do not + excuse you from the conditions of this License. If you cannot convey a + covered work so as to satisfy simultaneously your obligations under this + License and any other pertinent obligations, then as a consequence you may + not convey it at all. For example, if you agree to terms that obligate you + to collect a royalty for further conveying from those to whom you convey + the Program, the only way you could satisfy both those terms and this + License would be to refrain entirely from conveying the Program.

+ + 13. Use with the GNU Affero General Public License.

+ + Notwithstanding any other provision of this License, you have + permission to link or combine any covered work with a work licensed + under version 3 of the GNU Affero General Public License into a single + combined work, and to convey the resulting work. The terms of this + License will continue to apply to the part which is the covered work, + but the special requirements of the GNU Affero General Public License, + section 13, concerning interaction through a network will apply to the + combination as such.

+ + 14. Revised Versions of this License.

+ + The Free Software Foundation may publish revised and/or new versions of + the GNU General Public License from time to time. Such new versions will + be similar in spirit to the present version, but may differ in detail to + address new problems or concerns.

+ + Each version is given a distinguishing version number. If the + Program specifies that a certain numbered version of the GNU General + Public License "or any later version" applies to it, you have the + option of following the terms and conditions either of that numbered + version or of any later version published by the Free Software + Foundation. If the Program does not specify a version number of the + GNU General Public License, you may choose any version ever published + by the Free Software Foundation.

+ + If the Program specifies that a proxy can decide which future + versions of the GNU General Public License can be used, that proxy's + public statement of acceptance of a version permanently authorizes you + to choose that version for the Program.

+ + Later license versions may give you additional or different + permissions. However, no additional obligations are imposed on any + author or copyright holder as a result of your choosing to follow a + later version.

+ + 15. Disclaimer of Warranty.

+ + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF + ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

+ + 16. Limitation of Liability.

+ + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF + SUCH DAMAGES.

+ + 17. Interpretation of Sections 15 and 16.

+ + If the disclaimer of warranty and limitation of liability provided + above cannot be given local legal effect according to their terms, + reviewing courts shall apply local law that most closely approximates + an absolute waiver of all civil liability in connection with the + Program, unless a warranty or assumption of liability accompanies a + copy of the Program in return for a fee.

+ + END OF TERMS AND CONDITIONS

+ + How to Apply These Terms to Your New Programs

+ + If you develop a new program, and you want it to be of the greatest + possible use to the public, the best way to achieve this is to make it + free software which everyone can redistribute and change under these terms.

+ + To do so, attach the following notices to the program. It is safest + to attach them to the start of each source file to most effectively + state the exclusion of warranty; and each file should have at least + the "copyright" line and a pointer to where the full notice is found.

+ + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author}

+ + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version.

+ + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details.

+ + You should have received a copy of the GNU General Public License + along with this program. If not, see .

+ + Also add information on how to contact you by electronic and paper mail.

+ + If the program does terminal interaction, make it output a short + notice like this when it starts in an interactive mode:

+ + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details.

+ + The hypothetical commands `show w' and `show c' should show the appropriate + parts of the General Public License. Of course, your program's commands + might be different; for a GUI interface, you would use an "about box".

+ + You should also get your employer (if you work as a programmer) or school, + if any, to sign a "copyright disclaimer" for the program, if necessary. + For more information on this, and how to apply and follow the GNU GPL, see + .

+ + The GNU General Public License does not permit incorporating your program + into proprietary programs. If your program is a subroutine library, you + may consider it more useful to permit linking proprietary applications with + the library. If this is what you want to do, use the GNU Lesser General + Public License instead of this License. But first, please read + .

+
+ TOCropViewController

+ The MIT License (MIT)

+ + Copyright (c) 2015-2019 Tim Oliver

+ + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions:

+ + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software.

+ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE.

+
+ WebRTC

+ BSD 3-Clause License Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

+ + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

+ + 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.

+ + Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

+ + 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.

+ + Google WebRTC Copyright (c) 2011, The WebRTC project authors. All rights reserved.

+ + Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

+ + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

+ + 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.

+ + Neither the name of Google nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

+ + 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.

+
+ + diff --git a/Monal/shareSheet-iOS/Info.plist b/Monal/shareSheet-iOS/Info.plist new file mode 100644 index 0000000..6ef490b --- /dev/null +++ b/Monal/shareSheet-iOS/Info.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Monal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + NSExtensionActivationRule + + SUBQUERY ( + extensionItems, + $extensionItem, + SUBQUERY ( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.data" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.apple.pkpass" + ).@count >= 1 + ).@count == 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + ShareViewController + + + diff --git a/Monal/shareSheet-iOS/MLSelectionController.h b/Monal/shareSheet-iOS/MLSelectionController.h new file mode 100644 index 0000000..c34e5ef --- /dev/null +++ b/Monal/shareSheet-iOS/MLSelectionController.h @@ -0,0 +1,21 @@ +// +// MLSelectionController.h +// Monal +// +// Created by Anurodh Pokharel on 10/26/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN +typedef void(^selectionResult)(NSDictionary *); + +@interface MLSelectionController : UITableViewController + +@property (nonatomic, copy) selectionResult completion; +@property (nonatomic, strong) NSArray *options; // an Array of MlContact +@property (nonatomic, strong) NSDictionary *selection; +@end + +NS_ASSUME_NONNULL_END diff --git a/Monal/shareSheet-iOS/MLSelectionController.m b/Monal/shareSheet-iOS/MLSelectionController.m new file mode 100644 index 0000000..792564e --- /dev/null +++ b/Monal/shareSheet-iOS/MLSelectionController.m @@ -0,0 +1,72 @@ +// +// MLSelectionController.m +// Monal +// +// Created by Anurodh Pokharel on 10/26/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import "MLSelectionController.h" +#import "MLContact.h" + +@interface MLSelectionController () + +@end + +@implementation MLSelectionController + +-(void) viewDidLoad +{ + [super viewDidLoad]; +} + +#pragma mark - Table view data source + +-(NSInteger) numberOfSectionsInTableView:(UITableView*) tableView +{ + return 1; +} + +-(NSInteger) tableView:(UITableView*) tableView numberOfRowsInSection:(NSInteger) section +{ + return self.options.count; +} + + +-(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath +{ + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:@"option" forIndexPath:indexPath]; + NSDictionary* row = self.options[indexPath.row]; + MLContact* contact = (MLContact*)[row objectForKey:@"contact"]; + + if(contact) + { + cell.textLabel.text = [NSString stringWithFormat:@"%@ (%@)", contact.contactDisplayName, contact.contactJid]; + MLContact* selectedContact = (MLContact*)[self.selection objectForKey:@"contact"]; + if([selectedContact isEqualToContact:contact]) + cell.accessoryType = UITableViewCellAccessoryCheckmark; + else + cell.accessoryType = UITableViewCellAccessoryNone; + } + else + { + cell.textLabel.text = [NSString stringWithFormat:@"%@@%@",[row objectForKey:@"username"],[row objectForKey:@"domain"]]; + if([[self.selection objectForKey:@"account_id"] integerValue]==[[row objectForKey:@"account_id"] integerValue]) + cell.accessoryType = UITableViewCellAccessoryCheckmark; + else + cell.accessoryType = UITableViewCellAccessoryNone; + } + return cell; +} + +-(void) tableView:(UITableView*) tableView didSelectRowAtIndexPath:(NSIndexPath*) indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + self.selection = self.options[indexPath.row]; + [tableView reloadData]; + if(self.completion) + self.completion(self.selection); +} + + +@end diff --git a/Monal/shareSheet-iOS/ShareViewController.h b/Monal/shareSheet-iOS/ShareViewController.h new file mode 100644 index 0000000..aa00e63 --- /dev/null +++ b/Monal/shareSheet-iOS/ShareViewController.h @@ -0,0 +1,14 @@ +// +// ShareViewController.h +// shareSheet +// +// Created by Anurodh Pokharel on 9/10/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import +#import + +@interface ShareViewController : SLComposeServiceViewController + +@end diff --git a/Monal/shareSheet-iOS/ShareViewController.m b/Monal/shareSheet-iOS/ShareViewController.m new file mode 100644 index 0000000..66958fc --- /dev/null +++ b/Monal/shareSheet-iOS/ShareViewController.m @@ -0,0 +1,305 @@ +// +// ShareViewController.m +// shareSheet +// +// Created by Anurodh Pokharel on 9/10/18. +// Copyright © 2018 Monal.im. All rights reserved. +// + +#import "ShareViewController.h" +#import "MLSelectionController.h" + +#import "MLContact.h" +#import "MLConstants.h" +#import "HelperTools.h" +#import "DataLayer.h" +#import "MLFiletransfer.h" +#import "IPC.h" + +#import +#import + +@import Intents; +@import UniformTypeIdentifiers; + +@interface ShareViewController () + +@property (nonatomic, strong) NSArray* accounts; +@property (nonatomic, strong) NSArray* recipients; +@property (nonatomic, strong) MLContact* recipient; +@property (nonatomic, strong) NSDictionary* account; +@property (nonatomic, strong) MLContact* intentContact; + +@end + +//TODO: use this approach, but with swiftui: https://diamantidis.github.io/2020/01/11/share-extension-custom-ui +@implementation ShareViewController + ++(void) initialize +{ + [HelperTools initSystem]; + + //init IPC + [IPC initializeForProcess:@"ShareSheetExtension"]; + + //log startup + DDLogInfo(@"Share Sheet Extension started: %@", [HelperTools appBuildVersionInfoFor:MLVersionTypeLog]); + [DDLog flushLog]; +} + +-(void) viewDidLoad +{ + [super viewDidLoad]; + [self.navigationController.navigationBar setTintColor:UIColor.systemBackgroundColor]; + [self.navigationController.navigationBar setBackgroundColor:[UIColor colorNamed:@"monalGreen"]]; + self.navigationController.navigationItem.title = NSLocalizedString(@"Monal", @""); + + DDLogInfo(@"Extension context: %@", self.extensionContext); + DDLogDebug(@"Raw extension context intent: %@", self.extensionContext.intent); + if(self.extensionContext.intent != nil && [self.extensionContext.intent isKindOfClass:[INSendMessageIntent class]]) + { + INSendMessageIntent* intent = (INSendMessageIntent*)self.extensionContext.intent; + DDLogDebug(@"Got usable intent: %@", intent); + self.intentContact = [HelperTools unserializeData:[intent.conversationIdentifier dataUsingEncoding:NSISOLatin1StringEncoding]]; + DDLogInfo(@"Extracted intent contact: %@", self.intentContact); + [self.intentContact refresh]; //make sure we are up to date + } +} + +- (void) presentationAnimationDidFinish +{ + // list all contacts, not only active chats + // that will clutter the list of selectable contacts, but you can always use sirikit interactions + // to get the recently used contacts listed + self.recipients = [[DataLayer sharedInstance] contactList]; + self.accounts = [[DataLayer sharedInstance] enabledAccountList]; + + if(self.intentContact != nil) + { + DDLogInfo(@"Intent contact given: %@", self.intentContact); + //check if intentContact is in enabled account list + for(NSDictionary* accountToCheck in self.accounts) + { + NSNumber* accountID = [accountToCheck objectForKey:@"account_id"]; + if(accountID.intValue == self.intentContact.accountID.intValue) + { + self.recipient = self.intentContact; + self.account = accountToCheck; + break; + } + } + } + + //no intent given or intent contact not found --> select initial recipient (contact with most recent interaction) + if(!self.account || !self.recipient) + { + DDLogInfo(@"No recipient given, selecting the one with the most recent interaction..."); + BOOL recipientFound = NO; + for(MLContact* recipient in self.recipients) + { + for(NSDictionary* accountToCheck in self.accounts) + { + NSNumber* accountID = [accountToCheck objectForKey:@"account_id"]; + if(accountID.intValue == recipient.accountID.intValue) + { + self.recipient = recipient; + self.account = accountToCheck; + recipientFound = YES; + break; + } + if(recipientFound == YES) + break; + } + } + } + + [self reloadConfigurationItems]; +} + +-(MLContact* _Nullable) getLastContactForAccount:(NSNumber*) accountID +{ + for(MLContact* recipient in self.recipients) { + if(recipient.accountID.intValue == accountID.intValue) { + return recipient; + } + } + return nil; +} + +-(BOOL) isContentValid +{ + if(self.recipient != nil && self.account != nil) + return YES; + return NO; +} + +-(void) didSelectPost +{ + DDLogVerbose(@"input items: %@", self.extensionContext.inputItems); + NSExtensionItem* item = self.extensionContext.inputItems.firstObject; + DDLogVerbose(@"Attachments = %@", item.attachments); + + __block uint32_t loading = 0; //no need for @synchronized etc., because we access this var exclusively from the main thread + __block uint32_t saved = 0; //no need for @synchronized etc., because we access this var exclusively from the main thread + monal_void_block_t checkIfDone = ^{ + if(loading == 0) + { + if(self.contentText && [self.contentText length] > 0) + { + NSMutableDictionary* payload = [NSMutableDictionary new]; + payload[@"account_id"] = self.recipient.accountID; + payload[@"recipient"] = self.recipient.contactJid; + payload[@"type"] = @"text"; + payload[@"data"] = self.contentText; + DDLogDebug(@"Adding shareSheet comment payload: %@", payload); + [[DataLayer sharedInstance] addShareSheetPayload:payload]; + saved++; + } + [self.extensionContext completeRequestReturningItems:@[] completionHandler:^(BOOL expired __unused) { + if(saved > 0) + [self openMainApp]; + }]; + } + }; + for(NSItemProvider* provider in item.attachments) + { +// //text shares are also shared via comment field, so ignore them +// if([provider hasItemConformingToTypeIdentifier:UTTypePlainText.identifier]) +// continue; + DDLogVerbose(@"handling(%u) %@", loading, provider); + loading++; + [HelperTools handleUploadItemProvider:provider withCompletionHandler:^(NSMutableDictionary* payload) { + DDLogVerbose(@"Got handleUploadItemProvider callback with payload: %@", payload); + dispatch_async(dispatch_get_main_queue(), ^{ + if(payload == nil || payload[@"error"] != nil) + { + DDLogError(@"Could not save payload for sending: %@", payload[@"error"]); + NSString* message = NSLocalizedString(@"Monal was not able to send your attachment!", @""); + if(payload[@"error"] != nil) + message = [NSString stringWithFormat:NSLocalizedString(@"Monal was not able to send your attachment: %@", @""), [payload[@"error"] localizedDescription]]; + UIAlertController* unknownItemWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not send", @"") + message:message preferredStyle:UIAlertControllerStyleAlert]; + [unknownItemWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Abort", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { + [unknownItemWarning dismissViewControllerAnimated:YES completion:nil]; + [self.extensionContext completeRequestReturningItems:@[] completionHandler:nil]; + loading--; + checkIfDone(); + }]]; + [self presentViewController:unknownItemWarning animated:YES completion:nil]; + return; + } + + //text shares are also shared via comment field, so ignore them, if they contain the same contents + if([provider hasItemConformingToTypeIdentifier:UTTypePlainText.identifier] && self.contentText && [self.contentText length] > 0 && [payload[@"data"] isKindOfClass:[NSString class]] && [self.contentText isEqualToString:payload[@"data"]]) + { + DDLogWarn(@"Ignoring text payload because already sent via comment field"); + loading--; + checkIfDone(); + return; + } + + payload[@"account_id"] = self.recipient.accountID; + payload[@"recipient"] = self.recipient.contactJid; + DDLogDebug(@"Adding shareSheet payload(%u): %@", loading, payload); + [[DataLayer sharedInstance] addShareSheetPayload:payload]; + saved++; + loading--; + checkIfDone(); + }); + }]; + } + checkIfDone(); +} + +-(NSArray*) configurationItems +{ + NSMutableArray* toreturn = [NSMutableArray new]; + if(self.accounts.count > 1) + { + SLComposeSheetConfigurationItem* accountSelector = [SLComposeSheetConfigurationItem new]; + accountSelector.title = NSLocalizedString(@"Account", @"ShareViewController: Account"); + + accountSelector.value = [NSString stringWithFormat:@"%@@%@", [self.account objectForKey:@"username"], [self.account objectForKey:@"domain"]]; + accountSelector.tapHandler = ^{ + UIStoryboard* iosShareStoryboard = [UIStoryboard storyboardWithName:@"iosShare" bundle:nil]; + MLSelectionController* controller = (MLSelectionController*)[iosShareStoryboard instantiateViewControllerWithIdentifier:@"accounts"]; + controller.options = self.accounts; + controller.completion = ^(NSDictionary* selectedAccount) + { + if(selectedAccount != nil) { + self.account = selectedAccount; + } + else { + self.account = self.accounts[0]; // at least one account is present (count > 0) + } + self.recipient = [self getLastContactForAccount:[self.account objectForKey:@"account_id"]]; + [self reloadConfigurationItems]; + }; + + [self pushConfigurationViewController:controller]; + }; + [toreturn addObject:accountSelector]; + } + + if(!self.account && self.accounts.count > 0) + self.account = [self.accounts objectAtIndex:0]; + + SLComposeSheetConfigurationItem* recipient = [SLComposeSheetConfigurationItem new]; + recipient.title = NSLocalizedString(@"Recipient", @"shareViewController: recipient"); + recipient.value = [NSString stringWithFormat:@"%@ (%@)", self.recipient.contactDisplayName, self.recipient.contactJid]; + recipient.tapHandler = ^{ + UIStoryboard* iosShareStoryboard = [UIStoryboard storyboardWithName:@"iosShare" bundle:nil]; + MLSelectionController* controller = (MLSelectionController *)[iosShareStoryboard instantiateViewControllerWithIdentifier:@"contacts"]; + + // Create list of recipients for the selected account + NSMutableArray* recipientsToShow = [NSMutableArray new]; + for (MLContact* contact in self.recipients) + { + // only show contacts from the selected account + NSNumber* accountID = [self.account objectForKey:@"account_id"]; + if(contact.accountID.intValue == accountID.intValue) + [recipientsToShow addObject:@{@"contact": contact}]; + } + + controller.options = recipientsToShow; + controller.completion = ^(NSDictionary* selectedRecipient) { + MLContact* contact = [selectedRecipient objectForKey:@"contact"]; + if(contact) + self.recipient = contact; + else + self.recipient = nil; + [self reloadConfigurationItems]; + }; + + [self pushConfigurationViewController:controller]; + }; + [toreturn addObject:recipient]; + [self validateContent]; + return toreturn; +} + +-(void) openURL:(NSURL*) url +{ + UInt16 iterations = 0; + SEL openURLSelector = NSSelectorFromString(@"openURL:"); + UIResponder* responder = self; + while((responder = [responder nextResponder]) != nil && iterations++ < 16) + if([responder respondsToSelector:openURLSelector] == YES) + { + UIApplication* app = (UIApplication*)responder; + if(app != nil) + { + [app performSelector:@selector(openURL:) withObject:url]; + break; + } + } +} + +-(void) openMainApp +{ + DDLogInfo(@"Now opening mainapp via %@...", kMonalOpenURL); + NSURL* mainAppUrl = kMonalOpenURL; + [self openURL:mainAppUrl]; +} + +@end diff --git a/Monal/shareSheet-iOS/localization/Base.lproj/iosShare.storyboard b/Monal/shareSheet-iOS/localization/Base.lproj/iosShare.storyboard new file mode 100644 index 0000000..2319314 --- /dev/null +++ b/Monal/shareSheet-iOS/localization/Base.lproj/iosShare.storyboard @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/shareSheet.entitlements b/Monal/shareSheet.entitlements new file mode 100644 index 0000000..17ceaad --- /dev/null +++ b/Monal/shareSheet.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.monal + + com.apple.security.network.client + + + diff --git a/Monal/sworim.sqlite b/Monal/sworim.sqlite new file mode 100644 index 0000000..2053f6a Binary files /dev/null and b/Monal/sworim.sqlite differ diff --git a/README.md b/README.md index 472b8ae..6b46c12 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,71 @@ -# Conversations Classic iOS client +# Monal for XMPP +## A cross platform, modern XMPP client for iOS and macOS +[![Alpha build status](https://github.com/monal-im/Monal/actions/workflows/develop-push.yml/badge.svg)](https://github.com/monal-im/Monal/actions/workflows/develop-push.yml) +[![Translation status](https://hosted.weblate.org/widgets/monal/-/svg-badge.svg)](https://hosted.weblate.org/engage/monal/?utm_source=widget) -This is the source code for the Conversations Classic iOS. +This is the Monal XMPP client as found in the app store. +If you want to use the latest stable versions, search for Monal in the iOS or OSX app store. +[Visit the blog to read about the development](https://monal-im.org/post)! -# License +## Releases +| | iOS | macOS | macOS (homebrew) | +|--------|---------------------------------------------------------------|----------------------------------------------------------|---------------------------------------------------------------------------| +| Stable | [App Store](https://apps.apple.com/app/id317711500) | [App Store](https://apps.apple.com/app/id1499227291) | brew install --cask monal | +| Beta | [Testflight](https://testflight.apple.com/join/lLLlgHpB) | [Testflight](https://testflight.apple.com/join/tGH2m5vf) | brew install --cask monal@beta | +| Alpha | upon request to [info@monal-im.org](mailto:info@monal-im.org)
Then download from our [alpha download site](https://downloads.monal-im.org/monal-im/alpha/) | | brew tap monal-im/homebrew-monal-alpha
brew install --cask monal-alpha | -Copyright (c) 2024 Narayana OÜ -Licensed under GPL License Version 3. +## Support Chat (MUC) and Wiki + +You can join this public chat (MUC) via XMPP: [monal@chat.yax.im](xmpp:monal@chat.yax.im?join) + +Find general information in the [Monal Wiki](https://github.com/monal-im/Monal/wiki). + +[Reporting security issues](SECURITY.md) + +## Donations and Support + +Monal is developed by volunteers and community collaboration. The work which has been done is usually not paid and the developers ask for donations to keep up service costs and development in the future! Please consider to give a little bit back for the hard work which has been conducted. Currently there are three ways for financial support of the Monal development: + +- Donate via [GitHub Sponsors](https://github.com/sponsors/tmolitor-stud-tu) +- Donate via [Libera Pay](https://liberapay.com/tmolitor) +- EU citizens can donate via SEPA, too. IBAN: DE66 5007 0371 0856 0419 01 + +Here you can read about further [support of the development](https://github.com/monal-im/Monal/issues/363)! + +### Translations + +We host and manage translations via [Weblate](https://hosted.weblate.org/engage/monal/). + +[![Detailed translation status](https://hosted.weblate.org/widgets/monal/-/multi-auto.svg)](https://hosted.weblate.org/engage/monal/?utm_source=widget) + +### Platform information + +Monal always supports the two latest MacOS and iOS major releases. + +### Supported XEPs + +Take a look at this list to get information on [supported XEPs by Monal](https://monal-im.org/install/#implemented-xeps). + +### iOS Screenshots +

+ + + +

+ +### iPad (and macOS) Screenshot + + + +### License +Monal is licensed under the BSD license. Any code contributions should be compatible with that license. ** NO GPL ** . By contributing to this project, you agree that your code is not GPL or any similarly restrictive license. You agree that your code can be used to publish in App stores such as Apple's that use DRM. + +### Pull Requests +We take pull requests. Please use the develop branch to make changes. Please take a look at: + +- [Building Monal](https://github.com/monal-im/Monal/wiki/Building-Monal) +- [Issues with a Help wanted label](https://github.com/monal-im/Monal/issues?q=is%3Aissue+is%3Aopen+label%3A%22%3Asuperhero%3A+Help+wanted%22) +- [Support Monal](https://github.com/monal-im/Monal/issues/363) + +Monal is licensed under the BSD license. Any code contributions should be compatible with that license. ** NO GPL ** . By contributing to this project, you agree that your code is not GPL or any similarly restrictive license. You agree that your code can be used to publish in App stores such as Apple's that use DRM. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c64e01f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a Vulnerability + +It is highly appreciated to report a vulnerability to the Monal developers. We kindly ask you to not +disclose it until it has been fixed. This prevents abuse and exploitation in the current published releases. + +Please report issues directly via mail to info@monal-im.org. + +Please try to report in detail: +- what you are concerned about +- if applicable, how to reproduce +- your contact details, if the sending email is not enough. That way we can ask questions back to you. + +You are also invited to make a recommendation on how to fix a potential security vulnerability. + +Once a vulnerability has been reported and confirmed we try our very best to provide a fix as soon as possible, +at its best within days. However, depending on the potential issue it can take longer if many code sections need to be changed. +Please keep in mind that this is a non-commercial software project run by volunteers. + +Thank you for considering to report a security vulnerability. This improves the quality of the app significantly. diff --git a/UDPLogServer/requirements.txt b/UDPLogServer/requirements.txt new file mode 100644 index 0000000..13b47d9 --- /dev/null +++ b/UDPLogServer/requirements.txt @@ -0,0 +1,2 @@ +pycryptodomex==3.19.1 +xtermcolor==1.3 diff --git a/UDPLogServer/server.py b/UDPLogServer/server.py new file mode 100755 index 0000000..e7a963c --- /dev/null +++ b/UDPLogServer/server.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +import sys +import argparse +import socket +import ipaddress +import json +import zlib +import hashlib +import struct +import pathlib + +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + +# import optional/alternative modules +try: + from xtermcolor import colorize +except ImportError as e: + eprint(e) + def colorize(text, rgb=None, ansi=None, bg=None, ansi_bg=None, fd=1, **kwargs): + print(text, **kwargs) +try: + from Cryptodome.Cipher import AES # pycryptodomex +except ImportError as e: + from Crypto.Cipher import AES # pycryptodome + +def flag_to_kwargs(flag): + kwargs = {} + if flag != None: + if flag & 1: # error + kwargs = {"ansi": 9, "ansi_bg": 0} + elif flag & 2: # warning + kwargs = {"ansi": 208, "ansi_bg": 0} + elif flag & 4: # info + kwargs = {"ansi": 40, "ansi_bg": None} + elif flag & 8: # debug + kwargs = {"ansi": 39, "ansi_bg": None} + elif flag & 16: # verbose + kwargs = {"ansi": 7, "ansi_bg": None} + elif flag & 32: # stderr + kwargs = {"ansi": 9, "ansi_bg": None} + elif flag & 64: # stdout + kwargs = {"ansi": 0, "ansi_bg": None} + else: + kwargs = {"ansi": 0, "ansi_bg": None} + return kwargs + +def decrypt(ciphertext, key): + iv = ciphertext[:12] + if len(iv) != 12: + raise Exception("Cipher text is damaged: invalid iv length") + + tag = ciphertext[12:28] + if len(tag) != 16: + raise Exception("Cipher text is damaged: invalid tag length") + + encrypted = ciphertext[28:] + + # Construct AES cipher, with old iv. + cipher = AES.new(key, AES.MODE_GCM, iv) + + # Decrypt and verify. + try: + plaintext = cipher.decrypt_and_verify(encrypted, tag) + except ValueError as e: + raise Exception("Cipher text is damaged: {}".format(e)) + return plaintext + +def formatLogline(entry): + LOGLEVELS = {v: k for k, v in { + "ERROR": 1, + "WARNING": 2, + "INFO": 4, + "DEBUG": 8, + "VERBOSE": 16, + "STDERR": 32, + "STDOUT": 64, + "STATUS": 256, + }.items()} + file = pathlib.PurePath(entry["file"]) + return "%s [%s] %s [%s (QOS:%s)] %s at %s:%lu: %s" % ( + entry["timestamp"], + LOGLEVELS[entry["flag"]].rjust(6), + entry["tag"]["processName"], + "%s:%s" % (entry["threadID"], entry["tag"]["queueThreadLabel"]) if entry["threadID"] != entry["tag"]["queueThreadLabel"] else entry["threadID"], + entry["tag"]["qosName"], + entry["function"], + "%s/%s" % (file.parent.name, file.name), + entry["line"], + entry["message"], + ) + +# parse commandline +parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, description="Monal UDP-Logserver.", epilog="WARNING: WE DO NOT ENHANCE ENTROPY!! PLEASE MAKE SURE TO USE A ENCRYPTION KEY WITH PROPER ENTROPY!!") +parser.add_argument("-k", "--key", type=str, required=True, metavar='KEY', help="AES-Key to use for decription of incoming data") +parser.add_argument("-l", "--listen", type=str, metavar='HOSTNAME', help="Local hostname or IP to listen on (Default: :: e.g. any)", default="::") +parser.add_argument("-p", "--port", type=int, metavar='PORT', help="Port to listen on (Default: 5555)", default=5555) +parser.add_argument("-f", "--file", type=str, required=False, metavar='FILE', help="Filename to write the log to (in addition to stdout)") +parser.add_argument("-r", "--rawfile", type=str, required=False, metavar='RAW', help="Filename to write the RAW log to") +args = parser.parse_args() + +# "derive" 256 bit key +m = hashlib.sha256() +m.update(bytes(args.key, "UTF-8")) +key = m.digest() + +# create listening udp socket and process all incoming packets +sock = socket.socket(socket.AF_INET6 if ipaddress.ip_address(args.listen).version==6 else socket.AF_INET, socket.SOCK_DGRAM) +sock.bind((args.listen, args.port)) +last_counter = None +last_processID = None +logfd = None +rawfd = None +receiveCounter = 0 +if args.file: + print(colorize("Opening logfile '%s' for writing..." % args.file, ansi=15, ansi_bg=0), flush=True) + logfd = open(args.file, "w") +if args.rawfile: + print(colorize("Opening RAW logfile '%s' for writing..." % args.rawfile, ansi=15, ansi_bg=0), flush=True) + rawfd = open(args.rawfile, "wb") +while True: + # receive raw udp packet + payload, client_address = sock.recvfrom(65536) + + # decrypt raw data + try: + payload = decrypt(payload, key) + except Exception as e: + eprint(e) + continue # process next udp packet + + # decompress raw data + payload = zlib.decompress(payload, zlib.MAX_WBITS | 16) + + # log to RAW file + if rawfd: + size = struct.pack("!L", len(payload)) + rawfd.write(size+payload) + + # decode raw json encoded data + decoded = json.loads(str(payload, "UTF-8")) + + # increment local receive counter and add it to data + receiveCounter += 1 + decoded["_receiveCounter"] = receiveCounter + + # check if counter jumped over some lines + logline = "" + if last_processID != None and decoded["tag"]["processID"] != last_processID: + logline += "PROCESS SWITCH FROM %s TO %s" % (last_processID, decoded["tag"]["processID"]) + if last_counter != None and decoded["tag"]["counter"] != last_counter + 1: + if len(logline) != 0: + logline += ": " + logline += "counter jumped from %d to %d leaving out %d lines" % (last_counter, decoded["tag"]["counter"], decoded["tag"]["counter"] - last_counter - 1) + if len(logline) != 0: + if logfd: + print(logline, file=logfd) + print(colorize(logline, ansi=15, ansi_bg=0), flush=True) + + # deduce log color from loglevel + kwargs = flag_to_kwargs(decoded["flag"] if "flag" in decoded else None) + + # print original formatted log message + logline = ("%d: %s" % (decoded["tag"]["counter"], formatLogline(decoded))) + if logfd: + print(logline, file=logfd) + print(colorize(logline, **kwargs), flush=True) + + # update state + last_processID = decoded["tag"]["processID"] + last_counter = decoded["tag"]["counter"] diff --git a/appstore_metadata/ar/description.txt b/appstore_metadata/ar/description.txt new file mode 100644 index 0000000..d935bc3 --- /dev/null +++ b/appstore_metadata/ar/description.txt @@ -0,0 +1,20 @@ +لم يكن هناك وقت أفضل من الآن للانضمام إلى XMPP، شبكة دردشة عامة حرة لا يتحكم أو يمتلكها أحد. Monal وسيلة سريعة وسهلة للتعامل مع XMPP. ما عليك سوى تنزيل التطبيق، وتسجيل الدخول أو التسجيل، وستكون جاهزًا للدردشة في غضون دقائق. يبدو ويعمل بنفس الطريقة التي تعمل بها التطبيقات الأخرى، لذا لا داعي لـ "تعلم XMPP" أو حتى الاهتمام بما هو عليه. + +الميزات: +- مفتوح المصدر +- بلا اعلانات! يركز على الخصوصية. بلا برمجيات تتبع. +- لا يقرأ اي بيانات شخصية. +- من خلال الاتصال المباشر بخادمك، لن يتم إرسال كلمة مرورك وجميع المعلومات الأخرى إلى جهة خارجية أبدًا. +- تشفير المحادثة بـ OMEMO. +- يعمل مع خوادم XMPP الخاصة بالشركات التي تتطلب VPN. +- المحادثة المتعددة مدعومة للمجموعات Multi user chat (MUC). +- مكالمات صوتية/فيديو. + +Implements certain XMPP extensions intended to improve mobile communication: +- XEP-0357: Push Notifications +- XEP-0280: Message Carbons keep messages in synch between clients +- XEP-0198: Stream Management to quickly reconnect +- XEP-0199: XMPP Ping to maintain connections +- XEP-0313: Message Archive Management to download chat history +- XEP-0352: Client State Indication for dramatic reduction on power use +- XEP-0363: HTTP File Upload to send images in conversations diff --git a/appstore_metadata/ar/keywords.txt b/appstore_metadata/ar/keywords.txt new file mode 100644 index 0000000..cc2016d --- /dev/null +++ b/appstore_metadata/ar/keywords.txt @@ -0,0 +1 @@ +xmpp, jabber, chat, instant messaging, messaging, ejabberd, prosody, OMEMO \ No newline at end of file diff --git a/appstore_metadata/ar/marketing_url.txt b/appstore_metadata/ar/marketing_url.txt new file mode 100644 index 0000000..43952eb --- /dev/null +++ b/appstore_metadata/ar/marketing_url.txt @@ -0,0 +1 @@ +https://monal-im.org/ \ No newline at end of file diff --git a/appstore_metadata/ar/privacy_url.txt b/appstore_metadata/ar/privacy_url.txt new file mode 100644 index 0000000..4c9b027 --- /dev/null +++ b/appstore_metadata/ar/privacy_url.txt @@ -0,0 +1 @@ +https://monal-im.org/privacy/ \ No newline at end of file diff --git a/appstore_metadata/ar/support_url.txt b/appstore_metadata/ar/support_url.txt new file mode 100644 index 0000000..1ffe2f0 --- /dev/null +++ b/appstore_metadata/ar/support_url.txt @@ -0,0 +1 @@ +https://monal-im.org/support/ \ No newline at end of file diff --git a/appstore_metadata/de-DE/description.txt b/appstore_metadata/de-DE/description.txt new file mode 100644 index 0000000..04373ec --- /dev/null +++ b/appstore_metadata/de-DE/description.txt @@ -0,0 +1,20 @@ +Es gab noch nie einen besseren Zeitpunkt, um in XMPP einzusteigen, ein kostenloses öffentliches Chat-Netzwerk, das niemand kontrolliert oder besitzt. Monal ist ein schneller und benutzerfreundlicher Weg zur Nutzung von XMPP. Lade einfach die App herunter, melde dich an oder registriere dich, und schon kannst du in wenigen Minuten chatten. + +Wesentliche Merkmale: +- Open Source +- Keine Werbung! Starker Fokus auf Privatsphäre. Ruft nicht zu Hause an und hat keine "Messdaten"-Software. +- Liest keine persönlichen Informationen aus. +- Mit einer direkten Verbindung zu deinem Server, werden dein Passwort und alle anderen Informationen niemals an Dritte gesendet. +- OMEMO verschlüsselter Chat +- Funktioniert mit XMPP-Servern von Unternehmen, die VPN erfordern +- Mehrbenutzer-Chats in Gruppen oder Channels +- Audio/Video-Anrufe + +Implementiert bestimmte XMPP-Erweiterungen, um die mobile Kommunikation zu verbessern. +- XEP-0357: Push-Benachrichtigungen +- XEP-0280: Nachrichtenkopien halten Nachrichten zwischen Clients synchron +- XEP-0198: Stream-Management zum schnellen Wiederverbinden +- XEP-0199: XMPP-Ping zur Aufrechterhaltung von Verbindungen +- XEP-0313: Nachrichtenarchivverwaltung zum Herunterladen des Chatverlaufs +- XEP-0352: Client-Statusanzeige zur drastischen Reduzierung des Stromverbrauchs +- XEP-0363: HTTP-Dateitransfer zum Senden von Bildern in Unterhaltungen diff --git a/appstore_metadata/de-DE/keywords.txt b/appstore_metadata/de-DE/keywords.txt new file mode 100644 index 0000000..4c718ed --- /dev/null +++ b/appstore_metadata/de-DE/keywords.txt @@ -0,0 +1 @@ +xmpp, jabber, Chat, messenger, instant messaging, messaging, ejabberd, prosody, OMEMO diff --git a/appstore_metadata/de-DE/marketing_url.txt b/appstore_metadata/de-DE/marketing_url.txt new file mode 100644 index 0000000..43952eb --- /dev/null +++ b/appstore_metadata/de-DE/marketing_url.txt @@ -0,0 +1 @@ +https://monal-im.org/ \ No newline at end of file diff --git a/appstore_metadata/de-DE/privacy_url.txt b/appstore_metadata/de-DE/privacy_url.txt new file mode 100644 index 0000000..4c9b027 --- /dev/null +++ b/appstore_metadata/de-DE/privacy_url.txt @@ -0,0 +1 @@ +https://monal-im.org/privacy/ \ No newline at end of file diff --git a/appstore_metadata/de-DE/support_url.txt b/appstore_metadata/de-DE/support_url.txt new file mode 100644 index 0000000..1ffe2f0 --- /dev/null +++ b/appstore_metadata/de-DE/support_url.txt @@ -0,0 +1 @@ +https://monal-im.org/support/ \ No newline at end of file diff --git a/appstore_metadata/en-US/description.txt b/appstore_metadata/en-US/description.txt new file mode 100644 index 0000000..652c0fd --- /dev/null +++ b/appstore_metadata/en-US/description.txt @@ -0,0 +1,20 @@ +There has never been a better time to get into XMPP, a free public chat network no one controls or owns. Monal is a fast and user friendly way to use XMPP. Just download the app, login or register and you are ready to chat in minutes. It looks and works the way other apps do, so there is no need to “learn XMPP” or even care what it is. + +Notable features: +- Open Source +- No Ads! Strong focus on privacy. Does not phone home and does not have software "metrics" +- Does not read any personal information +- With a direct connection to your server, your password and all other info are never sent to a third-party +- OMEMO encrypted chat +- Will work with corporate XMPP servers that require VPN +- Multi user chat (MUC) support for group chats +- Audio/Video calls + +Implements certain XMPP extensions intended to improve mobile communication: +- XEP-0357: Push Notifications +- XEP-0280: Message Carbons keep messages in synch between clients +- XEP-0198: Stream Management to quickly reconnect +- XEP-0199: XMPP Ping to maintain connections +- XEP-0313: Message Archive Management to download chat history +- XEP-0352: Client State Indication for dramatic reduction on power use +- XEP-0363: HTTP File Upload to send images in conversations diff --git a/appstore_metadata/en-US/keywords.txt b/appstore_metadata/en-US/keywords.txt new file mode 100644 index 0000000..cc2016d --- /dev/null +++ b/appstore_metadata/en-US/keywords.txt @@ -0,0 +1 @@ +xmpp, jabber, chat, instant messaging, messaging, ejabberd, prosody, OMEMO \ No newline at end of file diff --git a/appstore_metadata/en-US/marketing_url.txt b/appstore_metadata/en-US/marketing_url.txt new file mode 100644 index 0000000..43952eb --- /dev/null +++ b/appstore_metadata/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://monal-im.org/ \ No newline at end of file diff --git a/appstore_metadata/en-US/privacy_url.txt b/appstore_metadata/en-US/privacy_url.txt new file mode 100644 index 0000000..4c9b027 --- /dev/null +++ b/appstore_metadata/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://monal-im.org/privacy/ \ No newline at end of file diff --git a/appstore_metadata/en-US/support_url.txt b/appstore_metadata/en-US/support_url.txt new file mode 100644 index 0000000..1ffe2f0 --- /dev/null +++ b/appstore_metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://monal-im.org/support/ \ No newline at end of file diff --git a/appstore_metadata/es-ES/description.txt b/appstore_metadata/es-ES/description.txt new file mode 100644 index 0000000..71c40d6 --- /dev/null +++ b/appstore_metadata/es-ES/description.txt @@ -0,0 +1,20 @@ +Nunca hubo un mejor momento para empezar a usar XMPP, una red de pública de chat que no se encuentra bajo el control ni la propiedad de nadie. Monal es la vía rápida y amigable para usar XMPP. Solo descargue la app, ingrese o regístrese y estará listo para chatear en minutos. Se ve y se usa de la misma manera que las otras apps, así que no existe necesidad de “aprender XMPP” o preocuparse por lo que es. + +Características más notables: +- Código abierto +- Sin publicidad! Fuerte foco en la privacidad. No envía información de uso ni "métricas" +- No recaba ningún tipo de información personal +- Con conexión directa al servidor, su contraseña y el resto de la información nunca se envían a terceros +- Chat encriptado con OMEMO +- Funciona con servidores corporativos de XMPP que requieren una VPN +- Soporta chat multi usuario (MUC - Multi User Chat) para chats en grupo +- Llamadas de Audio y Video + +Implementa ciertas extensiones XMPP destinadas a mejorar la comunicación movil: +- XEP-0357: Notificaciones automáticas +- XEP-0280: Copia sincronizada de mensajes entre los distintos clientes +- XEP-0198: Gestión de flujo de datos para una reconexión rápida +- XEP-0199: Ping XMPP para mantener la conexión +- XEP-0313: Administrador de archivo de mensajes para descarga del historial de chat +- XEP-0352: Visualización de estado del cliente para reducir dramáticamente el uso de la batería +- XEP-0363: Carga de archivos HTTP para enviar imágenes en las conversaciones diff --git a/appstore_metadata/es-ES/keywords.txt b/appstore_metadata/es-ES/keywords.txt new file mode 100644 index 0000000..500d380 --- /dev/null +++ b/appstore_metadata/es-ES/keywords.txt @@ -0,0 +1 @@ +xmpp, jabber, chat, instant messaging, messaging, ejabberd, prosody, OMEMO diff --git a/appstore_metadata/es-ES/marketing_url.txt b/appstore_metadata/es-ES/marketing_url.txt new file mode 100644 index 0000000..725b065 --- /dev/null +++ b/appstore_metadata/es-ES/marketing_url.txt @@ -0,0 +1 @@ +https://monal-im.org/ diff --git a/appstore_metadata/es-ES/privacy_url.txt b/appstore_metadata/es-ES/privacy_url.txt new file mode 100644 index 0000000..bc77981 --- /dev/null +++ b/appstore_metadata/es-ES/privacy_url.txt @@ -0,0 +1 @@ +https://monal-im.org/privacy/ diff --git a/appstore_metadata/es-ES/support_url.txt b/appstore_metadata/es-ES/support_url.txt new file mode 100644 index 0000000..2ee0b74 --- /dev/null +++ b/appstore_metadata/es-ES/support_url.txt @@ -0,0 +1 @@ +https://monal-im.org/support/ diff --git a/appstore_metadata/fr-FR/description.txt b/appstore_metadata/fr-FR/description.txt new file mode 100644 index 0000000..4698ba7 --- /dev/null +++ b/appstore_metadata/fr-FR/description.txt @@ -0,0 +1,20 @@ +C’est le meilleur moment pour se mettre à XMPP, un réseau de chat public libre que personne ne contrôle ou possède. Monal est une application facile à utiliser pour rejoindre le réseau XMPP. Téléchargez l’application, créez un compte, et ça y est vous pouvez chatter en quelques minutes. Elle est fonctionnellement identique aux applications de chat que vous connaissez, donc il n’y a aucun besoin d’« apprendre XMPP » ou même de se préoccuper de ce que c’est. + +Fonctionalités notables : +- Open Source +- Aucune pub ! Mettant l’accent sur la vie privée, Monal n’utilise aucune fonctionnalité de tracking. +- Ne lit aucune information personnelle. +- Avec une connexion directe à votre serveur, votre mot de passe et toutes vos autres informations ne sont jamais envoyées à un tiers. +- Chat chiffré avec OMEMO. +- Fonctionne avec les serveurs XMPP d’entreprises qui nécessitent un VPN. +- Chat multi-utilisateur·ice·s grâce à MUC. +- Appels audio/video. + +Implémente certaines extensions XMPP pour améliorer les communications mobiles : +- XEP-0357: Push Notifications, pour avoir des notifications même quand l’application est fermée. +- XEP-0280: Message Carbons, pour garder les messages synchronisés entre clients. +- XEP-0198: Stream Management, pour se reconnecter rapidement. +- XEP-0199: XMPP Ping, pour maintenir la connexion. +- XEP-0313: Message Archive Management, pour récupérer l’historique des messages. +- XEP-0352: Client State Indication, pour diminuer drastiquement la consommation énergétique. +- XEP-0363: HTTP File Upload, pour envoyer des images, des messages vocaux ou des fichiers dans les conversations. diff --git a/appstore_metadata/fr-FR/keywords.txt b/appstore_metadata/fr-FR/keywords.txt new file mode 100644 index 0000000..8bb5821 --- /dev/null +++ b/appstore_metadata/fr-FR/keywords.txt @@ -0,0 +1 @@ +xmpp, jabber, chat, messagerie instantanée, messages, ejabberd, prosody, OMEMO diff --git a/appstore_metadata/fr-FR/marketing_url.txt b/appstore_metadata/fr-FR/marketing_url.txt new file mode 100644 index 0000000..43952eb --- /dev/null +++ b/appstore_metadata/fr-FR/marketing_url.txt @@ -0,0 +1 @@ +https://monal-im.org/ \ No newline at end of file diff --git a/appstore_metadata/fr-FR/privacy_url.txt b/appstore_metadata/fr-FR/privacy_url.txt new file mode 100644 index 0000000..4c9b027 --- /dev/null +++ b/appstore_metadata/fr-FR/privacy_url.txt @@ -0,0 +1 @@ +https://monal-im.org/privacy/ \ No newline at end of file diff --git a/appstore_metadata/fr-FR/support_url.txt b/appstore_metadata/fr-FR/support_url.txt new file mode 100644 index 0000000..1ffe2f0 --- /dev/null +++ b/appstore_metadata/fr-FR/support_url.txt @@ -0,0 +1 @@ +https://monal-im.org/support/ \ No newline at end of file diff --git a/appstore_metadata/ro/description.txt b/appstore_metadata/ro/description.txt new file mode 100644 index 0000000..2292033 --- /dev/null +++ b/appstore_metadata/ro/description.txt @@ -0,0 +1,20 @@ +Nu a existat niciodată un moment mai bun pentru a utiliza XMPP, o rețea publică gratuită de chat pe care nimeni nu o controlează și nu o deține. Monal este un mod rapid și prietenos de a utiliza XMPP. Trebuie doar să descărcați aplicația, să vă autentificați sau să vă înregistrați și sunteți gata de discuții în câteva minute. Arată și funcționează la fel ca alte aplicații, deci nu este nevoie să "învățați XMPP" sau chiar să vă pese ce este. + +Caracteristici notabile: +- Sursă deschisă +- Fără reclame! Accent puternic pe confidențialitate. Nu transmite date altora și nu vă analizează acțiunile +- Nu citește nicio informație personală +- Cu o conexiune directă la serverul dvs., parola dvs. și toate celelalte informații nu sunt niciodată trimise unei terțe părți +- Discuții criptate cu OMEMO +- Va funcționa cu servere XMPP corporatiste care necesită VPN +- Discuții de grup MUC +- Apeluri audio/video + +Implementează anumite extensii XMPP menite să îmbunătățească comunicarea mobilă. +- XEP-0357: Notificări push +- XEP-0280: Message Carbons menține mesajele sincronizate între clienți +- XEP-0198: Stream Management pentru reconectarea rapidă +- XEP-0199: Ping XMPP pentru menținerea conexiunilor +- XEP-0313: Message Archive Management pentru a descărca istoricul conversațiilor +- XEP-0352: Client State Indication pentru reducerea drastică a consumului de energie +- XEP-0363: HTTP File Upload pentru a trimite imagini în conversații diff --git a/appstore_metadata/ro/keywords.txt b/appstore_metadata/ro/keywords.txt new file mode 100644 index 0000000..db66655 --- /dev/null +++ b/appstore_metadata/ro/keywords.txt @@ -0,0 +1 @@ +xmpp, jabber, discutie, mesagerie instantanee, mesagerie, ejabberd, prosody, OMEMO diff --git a/appstore_metadata/ro/marketing_url.txt b/appstore_metadata/ro/marketing_url.txt new file mode 100644 index 0000000..43952eb --- /dev/null +++ b/appstore_metadata/ro/marketing_url.txt @@ -0,0 +1 @@ +https://monal-im.org/ \ No newline at end of file diff --git a/appstore_metadata/ro/privacy_url.txt b/appstore_metadata/ro/privacy_url.txt new file mode 100644 index 0000000..4c9b027 --- /dev/null +++ b/appstore_metadata/ro/privacy_url.txt @@ -0,0 +1 @@ +https://monal-im.org/privacy/ \ No newline at end of file diff --git a/appstore_metadata/ro/support_url.txt b/appstore_metadata/ro/support_url.txt new file mode 100644 index 0000000..1ffe2f0 --- /dev/null +++ b/appstore_metadata/ro/support_url.txt @@ -0,0 +1 @@ +https://monal-im.org/support/ \ No newline at end of file diff --git a/appstore_quicksy_metadata/en-US/description.txt b/appstore_quicksy_metadata/en-US/description.txt new file mode 100644 index 0000000..a9a9ba9 --- /dev/null +++ b/appstore_quicksy_metadata/en-US/description.txt @@ -0,0 +1,5 @@ +Quicksy is a spin off of the popular XMPP client Monal with automatic contact discovery. + +You sign up with your phone number and Quicksy will automatically — based on the phone numbers in your address book — suggest possible contacts to you. Under the hood Quicksy is a full-fledged XMPP client that lets you communicate with any user on any publicly federating server. Likewise users on Quicksy can be contacted from the outside simply by adding +phonenumber@quicksy.im to your contact list. + +Aside from the contact sync the user interface is deliberately as close to Monal as possible. This allows users to eventually migrate from Quicksy to Monal without having to relearn how the app works. \ No newline at end of file diff --git a/appstore_quicksy_metadata/en-US/keywords.txt b/appstore_quicksy_metadata/en-US/keywords.txt new file mode 100644 index 0000000..cc2016d --- /dev/null +++ b/appstore_quicksy_metadata/en-US/keywords.txt @@ -0,0 +1 @@ +xmpp, jabber, chat, instant messaging, messaging, ejabberd, prosody, OMEMO \ No newline at end of file diff --git a/appstore_quicksy_metadata/en-US/marketing_url.txt b/appstore_quicksy_metadata/en-US/marketing_url.txt new file mode 100644 index 0000000..b53627e --- /dev/null +++ b/appstore_quicksy_metadata/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://quicksy.im/ \ No newline at end of file diff --git a/appstore_quicksy_metadata/en-US/privacy_url.txt b/appstore_quicksy_metadata/en-US/privacy_url.txt new file mode 100644 index 0000000..bb7d1c0 --- /dev/null +++ b/appstore_quicksy_metadata/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://quicksy.im/privacy.htm \ No newline at end of file diff --git a/appstore_quicksy_metadata/en-US/support_url.txt b/appstore_quicksy_metadata/en-US/support_url.txt new file mode 100644 index 0000000..b53627e --- /dev/null +++ b/appstore_quicksy_metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://quicksy.im/ \ No newline at end of file diff --git a/appstore_quicksy_metadata/ro/description.txt b/appstore_quicksy_metadata/ro/description.txt new file mode 100644 index 0000000..f49b9b9 --- /dev/null +++ b/appstore_quicksy_metadata/ro/description.txt @@ -0,0 +1,5 @@ +Quicksy este un derivat al popularului client XMPP Monal cu descoperire automată a contactelor. + +Vă înscrieți cu numărul de telefon, iar Quicksy vă va sugera automat, pe baza numerelor de telefon din agenda dvs., posibile contacte. Sub capota Quicksy este un client XMPP complet care vă permite să comunicați cu orice utilizator de pe orice server public federat. De asemenea, utilizatorii de pe Quicksy pot fi contactați din exterior prin simpla adăugare a +numărdetelefon@quicksy.im la lista dvs. de contacte. + +În afară de sincronizarea contactelor, interfața utilizatorului este în mod deliberat cât mai apropiată de Monal. Acest lucru permite utilizatorilor să migreze în cele din urmă de la Quicksy la Monal fără a fi nevoiți să învețe din nou cum funcționează aplicația. \ No newline at end of file diff --git a/appstore_quicksy_metadata/ro/keywords.txt b/appstore_quicksy_metadata/ro/keywords.txt new file mode 100644 index 0000000..db66655 --- /dev/null +++ b/appstore_quicksy_metadata/ro/keywords.txt @@ -0,0 +1 @@ +xmpp, jabber, discutie, mesagerie instantanee, mesagerie, ejabberd, prosody, OMEMO diff --git a/appstore_quicksy_metadata/ro/marketing_url.txt b/appstore_quicksy_metadata/ro/marketing_url.txt new file mode 100644 index 0000000..b53627e --- /dev/null +++ b/appstore_quicksy_metadata/ro/marketing_url.txt @@ -0,0 +1 @@ +https://quicksy.im/ \ No newline at end of file diff --git a/appstore_quicksy_metadata/ro/privacy_url.txt b/appstore_quicksy_metadata/ro/privacy_url.txt new file mode 100644 index 0000000..bb7d1c0 --- /dev/null +++ b/appstore_quicksy_metadata/ro/privacy_url.txt @@ -0,0 +1 @@ +https://quicksy.im/privacy.htm \ No newline at end of file diff --git a/appstore_quicksy_metadata/ro/support_url.txt b/appstore_quicksy_metadata/ro/support_url.txt new file mode 100644 index 0000000..b53627e --- /dev/null +++ b/appstore_quicksy_metadata/ro/support_url.txt @@ -0,0 +1 @@ +https://quicksy.im/ \ No newline at end of file diff --git a/monal.doap b/monal.doap new file mode 100644 index 0000000..fbf6f23 --- /dev/null +++ b/monal.doap @@ -0,0 +1,718 @@ + + + + + Monal IM + Monal + + Modern iOS and MacOS XMPP chat client + + There has never been a better time to get into XMPP, a free public chat network that no one controls or owns. + Monal is a fast and user friendly way to use the decentral XMPP protocol (known as Jabber). Just download the app, login or register and you are ready + to chat in minutes. Monal attempts to create a decent chat experience with XMPP in the Apple ecosystem. + + + + + + + + + + + + + + + + + + + + + Objective C + iOS + MacOS + + + + Thilo Molitor + + + + + + Friedrich Altheide + + + + + + + + + + + + + + + + + + complete + 2.13.1 + 4.9 + + + + + + wontfix + Superseded by OX + + + + + + complete + 2.5.0 + 4.6 + + + + + + partial + 1.34.6 + 5.0 + + + + + + wontfix + + + + + + complete + 1.2 + 5.0 + + + + + + wontfix + Not used anymore, use PubSub/Pep + + + + + + complete + 1.2 + 5.0 + Implemented only for MUC profiles + + + + + + complete + 1.0 + 4.8 + + + + + + partial + 1.26.0 + 4.9 + Used mainly for Pep + + + + + + wontfix + Use HTTP upload for filetransfers instead + + + + + + partial + 1.5 + 4.9 + Used to mark XEP-0363 filetransfers only + + + + + + wontfix + + + + + + partial + 2.4 + 4.7 + + + + + + complete + 1.1.4 + 4.9 + + + + + + partial + 2.1 + 4.7 + Only typing notifications, use XEP-0319 to publish interactions + + + + + + complete + 1.1 + 5.0 + + + + + + wontfix + + + + + + complete + 1.6.0 + 4.7 + + + + + + partial + 1.1.1 + 5.0 + XEP-0153: vCard-Based Avatars (implemented only for MUC profiles) + + + + + + complete + 1.1.1 + 7.0 + + + + + + planned + + + + + + partial + 0.2.1 + 5.4 + + + + + + complete + 1.2.2 + 4.9 + + + + + + complete + 1.2.2 + 6.0 + + + + + + complete + 1.1.1 + 6.0 + + + + + + complete + 1.1 + 4.9 + + + + + + complete + 1.4.0 + 4.7 + + + + + + complete + 1.3 + 5.0 + + + + + + complete + 1.6.1 + 4.6 + + + + + + complete + 2.0.1 + 4.7 + + + + + + complete + 1.0.0 + 6.0 + + + + + + complete + 1.1.1 + 4.9 + XEP-0223: Persistent Storage of Private Data via PubSub + + + + + + wontfix + Use HTTP filetransfer (XEP-0363) instead + + + + + + complete + 1.3 + 4.6 + + + + + + complete + 1.0 + 4.9 + + + + + + complete + 1.2 + 5.0 + XEP-0249: Direct MUC Invitations + + + + + + wontfix + Use HTTP filetransfer (XEP-0363) instead + + + + + + wontfix + Use HTTP filetransfer (XEP-0363) instead + + + + + + complete + 1.0.1 + 4.5 + + + + + + complete + 1.0.0 + 4.7 + + + + + + complete + 1.0.2 + 6.0 + + + + + + complete + 1.1.2 + 6.0 + + + + + + complete + 0.3 + 5.1.1 + + + + + + complete + 1.2.1 + 4.8 + + + + + + complete + 1.1.1 + 4.8 + + + + + + complete + 1.0.2 + 4.7 + + + + + + complete + 1.0.0 + 6.0 + + + + + + complete + 1.0.0 + 4.8 + + + + + + complete + 1.0.0 + 6.0 + + + + + + complete + 1.0.1 + 6.0 + + + + + + complete + 1.0.0 + 4.7 + + + + + + complete + 0.6.0 + 6.0 + + + + + + complete + 0.4.1 + 4.8 + + + + + + complete + 0.7.0 + 4.8 + + + + + + complete + 1.1.0 + 4.9 + + + + + + complete + 1.1.0 + 4.6 + + + + + + planned + + + + + + wontfix + Spam reporting done via XEP-0191 + + + + + + partial + 0.3.3 + 4.9 + No automatic approval if server does not support subscription pre-approval; No checking of tokens, if server does not do so (XEP-0401) + + + + + + complete + 0.4.0 + 5.1 + + + + + + complete + 0.3.0 + 4.8 + + + + + + planned + + + + + + complete + 1.0.1 + 6.0 + + + + + + wontfix + + + + + + complete + 1.0.0 + 5.1 + + + + + + wontfix + Use HTTP filetransfer (XEP-0363) instead + + + + + + complete + 1.0.0 + 6.0 + Used for MUC avatars + + + + + + complete + 0.5.0 + 6.0 + + + + + + complete + 1.1.4 + 5.4 + + + + + + complete + 1.1.0 + 5.0 + + + + + + planned + + + + + + partial + 1.0.1 + Check this XEP to see what's missing + + + + + + complete + 6.3 + 0.4.1 + + + + + + complete + 6.3 + 0.3.0 + + + + + + complete + 0.4.1 + 6.0 + + + + + + complete + 0.2.0 + 4.8 + Only to automatically turn on archiving if possible (setting: always) + + + + + + complete + 0.2.0 + 5.2 + + + + + + partial + 0.1.0 + 5.0 + No support for embedded thumbnails + + + + + + complete + 0.3.0 + 6.0 + + + + + + complete + 0.1.0 + 6.0 + + + + + + planned + + + + + + complete + 0.1.0 + 5.0 + + + + + + complete + 6.3 + 0.1.0 + + + + \ No newline at end of file diff --git a/push_filtering_entitlement.md b/push_filtering_entitlement.md new file mode 100644 index 0000000..ef680db --- /dev/null +++ b/push_filtering_entitlement.md @@ -0,0 +1,28 @@ +# Explain why existing APIs are not adequate for your app. +I'm a co-developer of the App Monal (see https://github.com/monal-im/Monal. +I'm using this dev-account to do an adhoc distribution of alpha-versions before my changes hit the betatesters via the main repo. + +The app is an xmpp instant messaging app, needing the ability to silence incoming pushes that hit the NotificationServiceExtension is essential, mainly because: +1. The federated xmpp servers often don't send the right amount of pushes which can result in more pushes than the server has messages to offer. +2. We want to provide the ability to dismiss notifications if a message was read on another device and mark the message as read inside the app (XEP-0333 fwiw) +3. some groupchat (MUC in xmpp speach) features are only possible if we can silence incoming pushes (e.g. only notify if my name is mentioned in the group) +4. muted threads are only possible when allowed to use this entitlement +5. Last message correction (XEP-0308) needs this, too +6. Supporting many more advanced XMPP features needs this, too, for example encrypted arbitrary payloads (XEP-0420) or incoming calls/filetransfers using jingle message initiation (XEP-035) + +# Explain why your app doesn’t show a visible notification each time a push notification is received. +Just see the previous explanation, many XMPP features can not be implemented (without heavily disturbing the user) if we are forced to show a visible notification each time we get a push notification. + +In the past Monal used VOIP pushes to do all of this, but since iOS 13 this sadly isn't possible anymore. + +# When your extension runs, what system and network resources does it need? +It needs internet access to connect to the remote XMPP server and receive whatever data is waiting to be retrieved, most probably incoming messages, message read dismissals etc. (see above). +The memory footprint should be minimal. The main app and the extension share the same codebase (except the UI parts of course) and running the main app in the simulator consumes about 23MB of memory and only a few KB of traffic to login to the xmpp account and retrieve the pending data. +Retrieving the data does take about 3-5 seconds if only a few messages are pending and slightly longer, if more data is pending. + +# How often does your extension run? What can trigger it to run? +The remote xmpp server the user uses can trigger an extension run if it deems it necessary (e.g. some messages are waiting). See XEP-357 and my push appserver implementation over here: https://github.com/tmolitor-stud-tu/mod_push_appserver/ + +The app uses CSI (XEP-0352) to tell the xmpp server if it is backgrounded, which will make sure the server will only send out pushes if it is absolutely necessary. +If the user is in many (active) groups or has very active chat partners the extension can be run every minute or maybe even slightly more often, if the user does not have such active chat contacts the extension might only be run every 2-12 hours or even less often. It mainly depends on the usage pattern of the user (and his contacts of course). + diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..705064a --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,993 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.82", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + +[[package]] +name = "cssparser" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.82", +] + +[[package]] +name = "derive_more" +version = "0.99.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.82", +] + +[[package]] +name = "dtoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c6ba7d4eec39eaa9ab24d44a0e73a7949a1095a8b3f3abb11eddf27dbb56a53" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "html5ever" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e15626aaf9c351bc696217cbe29cb9b5e86c43f8a46b5e2f5c6c5cf7cb904ce" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.82", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "libc" +version = "0.2.159" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82c88c6129bd24319e62a0359cb6b958fa7e8be6e19bb1663bc396b90883aca5" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "monal-html-parser" +version = "0.1.0" +dependencies = [ + "clap", + "scraper", +] + +[[package]] +name = "monal-panic-handler" +version = "0.1.0" + +[[package]] +name = "monal-rust-swift-bridge" +version = "0.1.0" +dependencies = [ + "monal-html-parser", + "monal-panic-handler", + "sdp-to-jingle", + "swift-bridge", + "swift-bridge-build", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.82", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbfb3ddf5364c9cfcd65549a1e7b801d0e8d1b14c1a1590a6408aa93cfbfa84" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e749d29b2064585327af5038a5a8eb73aeebad4a3472e83531a436563f7208" +dependencies = [ + "ahash", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "sdp-to-jingle" +version = "0.1.0" +dependencies = [ + "quick-xml", + "serde", + "serde_derive", + "webrtc-sdp", +] + +[[package]] +name = "selectors" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.82", +] + +[[package]] +name = "servo_arc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae65c4249478a2647db249fb43e23cec56a2c8974a427e7bd8cb5a1d0964921a" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-bridge" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca240710850bfee64549b2f86cd2c6fbbb3de64ce5f3da764cb1ecec5c6cc37" +dependencies = [ + "swift-bridge-build", + "swift-bridge-macro", +] + +[[package]] +name = "swift-bridge-build" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d0ea3c38460a65d975df382b71edbc0e22942d937710d63144267d6609ce738" +dependencies = [ + "proc-macro2", + "swift-bridge-ir", + "syn 1.0.109", + "tempfile", +] + +[[package]] +name = "swift-bridge-ir" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ea2dcd83a40a918fb26a1bb90187691aa0ae8f2c96e8ea5e6963207bc65676" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "swift-bridge-macro" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9b93285ca24a3fd596ecd316b217c4a0fd78c629e09efb1b4990fab1478183" +dependencies = [ + "proc-macro2", + "quote", + "swift-bridge-ir", + "syn 1.0.109", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-bidi" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "webrtc-sdp" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87d58624aae43577604ea137de9dcaf92793eccc4d816efad482001c2e055ca" +dependencies = [ + "log", + "serde", + "serde_derive", + "url", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.82", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..da7bf3c --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] + +members = [ + "sdp-to-jingle", + "monal-panic-handler", + "monal-rust-swift-bridge", + "monal-html-parser", +] + +resolver = "2" \ No newline at end of file diff --git a/rust/build-rust.sh b/rust/build-rust.sh new file mode 100644 index 0000000..9d7e465 --- /dev/null +++ b/rust/build-rust.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# based on https://github.com/chinedufn/swift-bridge/tree/master/book/src/building/swift-packages + +set -e + +THISDIR=$(dirname $0) +cd $THISDIR + +if [[ -f ~/.cargo/env ]]; +then + source ~/.cargo/env +fi + +echo "Installing required components" + +rustup +nightly component add rust-src +cargo install swift-bridge-cli +#rustup component add rust-src --toolchain x86_64-apple-ios-macabi +#rustup component add rust-src --toolchain darwin-apple-ios-macabi + +rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim + +echo "Building stdlib for the desired platforms..." +#cargo build --target x86_64-apple-darwin +#cargo build --target aarch64-apple-darwin +cargo +nightly build --verbose -Z build-std --target x86_64-apple-ios-macabi +cargo +nightly build --verbose -Z build-std --target aarch64-apple-ios-macabi + +BRIDGE_NAME=libmonal_rust_swift_bridge.a + +echo "Creating catalyst target universal lib..." +mkdir -p ./target/catalyst-macos/debug +lipo \ + ./target/x86_64-apple-ios-macabi/debug/$BRIDGE_NAME \ + ./target/aarch64-apple-ios-macabi/debug/$BRIDGE_NAME \ + -create -output \ + ./target/catalyst-macos/debug/$BRIDGE_NAME + +echo "Building rust code for all targets..." +cargo build --target aarch64-apple-ios +cargo build --target x86_64-apple-ios +cargo build --target aarch64-apple-ios-sim + +echo "Creating ios target universal lib..." +mkdir -p ./target/universal-ios/debug +lipo \ + ./target/aarch64-apple-ios-sim/debug/$BRIDGE_NAME \ + ./target/x86_64-apple-ios/debug/$BRIDGE_NAME \ + -create -output \ + ./target/universal-ios/debug/$BRIDGE_NAME + +echo "Creating swift package..." +swift-bridge-cli create-package \ + --bridges-dir ./monal-rust-swift-bridge/generated \ + --out-dir LibMonalRustSwiftBridge \ + --ios target/aarch64-apple-ios/debug/$BRIDGE_NAME \ + --simulator target/universal-ios/debug/$BRIDGE_NAME \ + --mac-catalyst target/catalyst-macos/debug/$BRIDGE_NAME \ + --name LibMonalRustSwiftBridge + +# copy over our own swift files +cp -av ./monal-rust-swift-bridge/swift/* LibMonalRustSwiftBridge/Sources/LibMonalRustSwiftBridge/ + +# make sure all autogenerated swift to rust wrapper functions are public +# TODO: maybe fix upstream? +sed -i '' 's/^func/public func/g' LibMonalRustSwiftBridge/Sources/LibMonalRustSwiftBridge/*.swift diff --git a/rust/monal-html-parser/Cargo.toml b/rust/monal-html-parser/Cargo.toml new file mode 100644 index 0000000..8acd166 --- /dev/null +++ b/rust/monal-html-parser/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "monal-html-parser" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["staticlib", "lib"] + +[dependencies] +clap = {version = "4.5.21", features = ["derive"]} +scraper = "0.21.0" diff --git a/rust/monal-html-parser/src/bin/parse_html.rs b/rust/monal-html-parser/src/bin/parse_html.rs new file mode 100644 index 0000000..7acc52f --- /dev/null +++ b/rust/monal-html-parser/src/bin/parse_html.rs @@ -0,0 +1,34 @@ +use clap::Parser; +use std::fs; +use std::io::Read; + +use monal_html_parser::MonalHtmlParser; + +/// Parse the given html file for text contents or attributes of given selector +#[derive(Parser)] +struct Cli { + /// The path to the file to read (use '-' for stdin) + path: std::path::PathBuf, + /// The selector to look for + selector: String, + /// An optional attribute name to return (omit to return text contents) + attribute: Option, +} + +fn main() -> Result<(), Box> { + let args = Cli::parse(); + println!( + "path: {:?}, selector: {:?}, attribute: {:?}", + args.path, args.selector, args.attribute + ); + let mut html = String::new(); + if args.path.as_os_str().to_str() == Some("-") { + std::io::stdin().lock().read_to_string(&mut html)?; + } else { + html = fs::read_to_string(args.path)?; + } + let parser = MonalHtmlParser::new(html); + let found = parser.select(args.selector, args.attribute); + println!("result: {:?}", found); + Ok(()) +} diff --git a/rust/monal-html-parser/src/lib.rs b/rust/monal-html-parser/src/lib.rs new file mode 100644 index 0000000..c6f91ae --- /dev/null +++ b/rust/monal-html-parser/src/lib.rs @@ -0,0 +1,38 @@ +use scraper::{Html, Selector}; + +pub struct MonalHtmlParser { + document: Html, +} + +impl MonalHtmlParser { + pub fn new(html: String) -> Self { + let document = Html::parse_document(&html); + MonalHtmlParser { document } + } + + pub fn select( + &self, + selector: String, + atrribute: Option, + ) -> Vec { + let mut retval = Vec::new(); + let sel = match Selector::parse(&selector) { + Ok(value) => value, + Err(error) => { + eprintln!("Selector '{selector}' parse error: {error}"); + return retval; + } + }; + for element in self.document.select(&sel) { + match atrribute { + Some(ref attr) => { + if let Some(val) = element.attr(attr) { + retval.push(val.to_string()) + } + } + None => retval.push(element.text().map(String::from).collect()), + }; + } + retval + } +} diff --git a/rust/monal-panic-handler/Cargo.toml b/rust/monal-panic-handler/Cargo.toml new file mode 100644 index 0000000..69095f2 --- /dev/null +++ b/rust/monal-panic-handler/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "monal-panic-handler" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/rust/monal-panic-handler/src/lib.rs b/rust/monal-panic-handler/src/lib.rs new file mode 100644 index 0000000..98437a1 --- /dev/null +++ b/rust/monal-panic-handler/src/lib.rs @@ -0,0 +1,40 @@ +use std::{backtrace::Backtrace, panic, thread}; // TODO: move this into its own rust lib? + +pub fn install_panic_handler(rust_panic_handler: F) { + let old_handler = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + eprintln!("RUST panic"); + + // format panic info (taken from https://docs.rs/log-panics/latest/src/log_panics/lib.rs.html#1-165) + let thread = thread::current(); + let thread = thread.name().unwrap_or(""); + let msg = match info.payload().downcast_ref::<&'static str>() { + Some(s) => *s, + None => match info.payload().downcast_ref::() { + Some(s) => &**s, + None => "Box", + }, + }; + let text = match info.location() { + Some(location) => { + format!( + "thread '{}' panicked with '{}': {}:{}", + thread, + msg, + location.file(), + location.line() + ) + } + None => format!("thread '{}' panicked with '{}'", thread, msg), + }; + let backtrace = format!("{}", Backtrace::force_capture()); + eprintln!("RUST panic: {}", text); + eprintln!("RUST backtrace: {}", backtrace); + + // call swift panic handler + rust_panic_handler(text.clone(), backtrace.clone()); + + // call original panic handler (should be never reached if a non-returning callback for rust_panic_handler() is set) + old_handler(info); + })); +} diff --git a/rust/monal-rust-swift-bridge/Cargo.toml b/rust/monal-rust-swift-bridge/Cargo.toml new file mode 100644 index 0000000..df789df --- /dev/null +++ b/rust/monal-rust-swift-bridge/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "monal-rust-swift-bridge" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["staticlib"] + +[dependencies] +swift-bridge = "0.1" +sdp-to-jingle = { path = "../sdp-to-jingle" } +monal-panic-handler = { path = "../monal-panic-handler" } +monal-html-parser = { path = "../monal-html-parser" } + + +[build-dependencies] +swift-bridge-build = "0.1" + +[dev-dependencies] +swift-bridge-build = "0.1" diff --git a/rust/monal-rust-swift-bridge/build.rs b/rust/monal-rust-swift-bridge/build.rs new file mode 100644 index 0000000..012c55d --- /dev/null +++ b/rust/monal-rust-swift-bridge/build.rs @@ -0,0 +1,15 @@ +// build.rs + +use std::path::PathBuf; + +fn main() { + let out_dir = PathBuf::from("./generated"); + + let bridges = vec!["src/lib.rs"]; + for path in &bridges { + println!("cargo:rerun-if-changed={}", path); + } + + swift_bridge_build::parse_bridges(bridges) + .write_all_concatenated(out_dir, env!("CARGO_PKG_NAME")); +} diff --git a/rust/monal-rust-swift-bridge/src/lib.rs b/rust/monal-rust-swift-bridge/src/lib.rs new file mode 100644 index 0000000..37faf36 --- /dev/null +++ b/rust/monal-rust-swift-bridge/src/lib.rs @@ -0,0 +1,46 @@ +use crate::ffi::rust_panic_handler; +use monal_html_parser::MonalHtmlParser; + +#[swift_bridge::bridge] +mod ffi { + //simple functions exported from rust to swift + extern "Rust" { + pub fn install_panichandler(); + pub fn trigger_panic(); + pub fn sdp_str_to_jingle_str(sdp_str: String, initiator: bool) -> Option; + pub fn jingle_str_to_sdp_str(jingle_str: String, initiator: bool) -> Option; + } + + //rust struct exported from rust to swift + extern "Rust" { + type MonalHtmlParser; + #[swift_bridge(init)] + pub fn new(html: String) -> MonalHtmlParser; + pub fn select( + &self, + selector: String, + atrribute: Option, + ) -> Vec; + } + + //exported from our internal swift helper to rust + extern "Swift" { + fn rust_panic_handler(text: String, backtrace: String); + } +} + +pub fn install_panichandler() { + monal_panic_handler::install_panic_handler(rust_panic_handler); +} + +pub fn trigger_panic() { + panic!("Dummy panic!"); +} + +pub fn sdp_str_to_jingle_str(sdp_str: String, initiator: bool) -> Option { + sdp_to_jingle::sdp_str_to_jingle_str(&sdp_str, initiator) +} + +pub fn jingle_str_to_sdp_str(jingle_str: String, initiator: bool) -> Option { + sdp_to_jingle::jingle_str_to_sdp_str(&jingle_str, initiator) +} diff --git a/rust/monal-rust-swift-bridge/swift/panichandling.swift b/rust/monal-rust-swift-bridge/swift/panichandling.swift new file mode 100644 index 0000000..81aa772 --- /dev/null +++ b/rust/monal-rust-swift-bridge/swift/panichandling.swift @@ -0,0 +1,11 @@ +public typealias rust_panic_handler_t = @convention(block) (String, String) -> Void; +var panicHandler: Optional = nil; +public func setRustPanicHandler(_ ph: @escaping rust_panic_handler_t) { + panicHandler = ph; + install_panichandler(); +} +public func rust_panic_handler(text: RustString, backtrace: RustString) { + if let ph = panicHandler { + ph(text.toString(), backtrace.toString()); + } +} diff --git a/rust/sdp-to-jingle/Cargo.toml b/rust/sdp-to-jingle/Cargo.toml new file mode 100644 index 0000000..7fe70ce --- /dev/null +++ b/rust/sdp-to-jingle/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sdp-to-jingle" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["staticlib", "lib"] + +[dependencies] +serde = {version = "1.0"} +serde_derive = {version = "1.0"} +quick-xml = { version = "0.37", features = ["serialize", "overlapped-lists"] } + +webrtc-sdp = {version = "0.3.10", features = ["serialize"] } diff --git a/rust/sdp-to-jingle/src/bin/parse_jingle.rs b/rust/sdp-to-jingle/src/bin/parse_jingle.rs new file mode 100644 index 0000000..0fcd94d --- /dev/null +++ b/rust/sdp-to-jingle/src/bin/parse_jingle.rs @@ -0,0 +1,12 @@ +use std::io; +use std::io::Read; + +use sdp_to_jingle::jingle_str_to_sdp_str; + +fn main() -> io::Result<()> { + let mut xml = String::new(); + std::io::stdin().lock().read_to_string(&mut xml)?; + let sdp = jingle_str_to_sdp_str(xml.as_str(), true).unwrap_or_else(|| "None".to_string()); + println!("{}", sdp); + Ok(()) +} diff --git a/rust/sdp-to-jingle/src/bin/parse_sdp.rs b/rust/sdp-to-jingle/src/bin/parse_sdp.rs new file mode 100644 index 0000000..b30efef --- /dev/null +++ b/rust/sdp-to-jingle/src/bin/parse_sdp.rs @@ -0,0 +1,12 @@ +use std::io; +use std::io::Read; + +use sdp_to_jingle::sdp_str_to_jingle_str; + +fn main() -> io::Result<()> { + let mut sdp = String::new(); + std::io::stdin().lock().read_to_string(&mut sdp)?; + let xml = sdp_str_to_jingle_str(sdp.as_str(), true).unwrap_or_else(|| "None".to_string()); + println!("{}", xml); + Ok(()) +} diff --git a/rust/sdp-to-jingle/src/bin/print_sdp_struct.rs b/rust/sdp-to-jingle/src/bin/print_sdp_struct.rs new file mode 100644 index 0000000..0559d19 --- /dev/null +++ b/rust/sdp-to-jingle/src/bin/print_sdp_struct.rs @@ -0,0 +1,16 @@ +use std::fs::read_to_string; + +use webrtc_sdp::parse_sdp; + +fn main() { + let sdp_txt = read_to_string("./examplesSdp/SDP-Audio-Monal.txt").unwrap(); + + match parse_sdp(&sdp_txt, true) { + Err(e) => { + println!("Could not read sdp: {}", e); + } + Ok(s) => { + println!("{:?}", s); + } + }; +} diff --git a/rust/sdp-to-jingle/src/jingle.rs b/rust/sdp-to-jingle/src/jingle.rs new file mode 100644 index 0000000..4d61654 --- /dev/null +++ b/rust/sdp-to-jingle/src/jingle.rs @@ -0,0 +1,126 @@ +use std::fmt::Write; + +use serde_derive::{Deserialize, Serialize}; + +use crate::xep_0167::{Content, JingleRtpSessionsBandwidth, JingleRtpSessionsPayloadType}; +use crate::xep_0293::{RtcpFb, RtcpFbTrrInt}; +use crate::xep_0294::JingleHdrext; +use crate::xep_0338::ContentGroup; +use crate::xep_0339::{JingleSsrc, JingleSsrcGroup}; + +// *** global +#[derive(Serialize, Deserialize, Default)] +#[serde(rename = "root")] +pub struct Root { + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty", default)] + childs: Vec, +} + +impl Root { + pub fn push(&mut self, element: RootEnum) { + self.childs.push(element); + } + + pub fn childs(&self) -> &Vec { + &self.childs + } +} + +// *** global +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RootEnum { + Content(Content), + Group(ContentGroup), + #[serde(other)] + Invalid, +} + +// *** global +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum JingleRtpSessionsValue { + PayloadType(JingleRtpSessionsPayloadType), + Bandwidth(JingleRtpSessionsBandwidth), + RtcpMux, + RtcpFb(RtcpFb), + RtcpFbTrrInt(RtcpFbTrrInt), + Source(JingleSsrc), + SsrcGroup(JingleSsrcGroup), + ExtmapAllowMixed, + RtpHdrext(JingleHdrext), + #[serde(other)] + Invalid, +} + +// *** generic enum for multiple xeps (e.g. global) +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum GenericParameterEnum { + Parameter(GenericParameter), + #[serde(other)] + Invalid, +} + +// *** generic struct for multiple xeps (e.g. global) +#[derive(Serialize, Deserialize, Clone)] +pub struct GenericParameter { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "@value", skip_serializing_if = "Option::is_none")] + value: Option, +} + +impl GenericParameter { + pub fn new(name: String, value: Option) -> Self { + Self { name, value } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn value(&self) -> Option<&str> { + self.value.as_deref() + } + + pub fn parse_parameter_string(attributes: &str) -> Vec { + let mut parameters_vec: Vec = Vec::new(); + // first: split by space + let splitted_vec = attributes.split(' '); + for splitted_part in splitted_vec { + if splitted_part.is_empty() { + continue; + } + // second: split by equal sign + let splitted_sub_part_vec = splitted_part.split('='); + let mut parameter_name: String = "".to_string(); + let mut parameter_value: Option = None; + for (i, value) in splitted_sub_part_vec.enumerate() { + if i == 0 { + parameter_name = value.to_string(); + } else if i == 1 { + parameter_value = Some(value.to_string()) + } else { + unreachable!() //should never happen + } + } + parameters_vec.push(Self::new(parameter_name, parameter_value)); + } + parameters_vec + } + + pub fn create_parameter_string(parameters: &Vec) -> String { + let mut retval: String = "".to_owned(); + for param in parameters { + if !retval.is_empty() { + retval.push(' '); + } + match ¶m.value { + Some(value) => write!(retval, "{}={}", param.name, value).unwrap(), + None => write!(retval, "{}", param.name).unwrap(), + } + } + retval + } +} diff --git a/rust/sdp-to-jingle/src/lib.rs b/rust/sdp-to-jingle/src/lib.rs new file mode 100644 index 0000000..fdac899 --- /dev/null +++ b/rust/sdp-to-jingle/src/lib.rs @@ -0,0 +1,79 @@ +use jingle::Root; +use webrtc_sdp::parse_sdp; +use xep_0167::JingleRtpSessions; + +mod jingle; +mod xep_0167; +mod xep_0176; +mod xep_0293; +mod xep_0294; +mod xep_0320; +mod xep_0338; +mod xep_0339; + +pub fn sdp_str_to_jingle_str(sdp_str: &str, initiator: bool) -> Option { + let sdp_session = match parse_sdp(sdp_str, true) { + Err(e) => { + eprintln!("Could not parse sdpstring: {}", e); + return None; + } + Ok(sdp) => sdp, + }; + + for warning in &sdp_session.warnings { + eprintln!("mozilla sdp parser warning: {}", warning); + } + + let jingle = match JingleRtpSessions::from_sdp(&sdp_session, initiator) { + Err(e) => { + eprintln!("Could not convert sdp to jingle: {}", e); + return None; + } + Ok(jingle) => jingle, + }; + + match quick_xml::se::to_string(&jingle) { + Err(e) => { + eprintln!("Could not serialize jingle to xmlstring: {}", e); + None + } + Ok(jingle_xml) => Some(jingle_xml), + } +} + +pub fn jingle_str_to_sdp_str(jingle_str: &str, initiator: bool) -> Option { + let jingle: Root = match quick_xml::de::from_str(jingle_str) { + Err(e) => { + eprintln!("Could not parse xmlstring: {}", e); + return None; + } + Ok(j) => j, + }; + + let sdp = match JingleRtpSessions::to_sdp(&jingle, initiator) { + Err(e) => { + eprintln!("Could not convert jingle to sdp: {}", e); + return None; + } + Ok(j) => j, + }; + + Some( + sdp.to_string() + .lines() + .collect::>() + .join("\r\n") + .to_string() + + "\r\n", + ) +} + +pub(crate) fn is_none_or_default(value: &Option) -> bool +where + T: Default + std::cmp::PartialEq, +{ + if let Some(inner_value) = value { + return *inner_value == T::default(); + } + true +} diff --git a/rust/sdp-to-jingle/src/xep_0167.rs b/rust/sdp-to-jingle/src/xep_0167.rs new file mode 100644 index 0000000..c765e77 --- /dev/null +++ b/rust/sdp-to-jingle/src/xep_0167.rs @@ -0,0 +1,999 @@ +use std::{ + any, + collections::{HashMap, HashSet}, + net::{IpAddr, Ipv4Addr}, +}; + +use serde_derive::{Deserialize, Serialize}; +use webrtc_sdp::{ + address::ExplicitlyTypedAddress, + attribute_type::{ + RtxFmtpParameters, SdpAttribute, SdpAttributeFmtp, SdpAttributeFmtpParameters, + SdpAttributeMsidSemantic, SdpAttributePayloadType, SdpAttributeRtcp, + SdpAttributeRtcpFbType, SdpAttributeRtpmap, SdpAttributeSsrc, + }, + error::SdpParserInternalError, + media_type::{SdpFormatList, SdpMedia, SdpMediaLine, SdpMediaValue, SdpProtocolValue}, + SdpBandwidth, SdpConnection, SdpOrigin, SdpSession, SdpTiming, +}; + +use crate::{ + is_none_or_default, + jingle::{GenericParameter, JingleRtpSessionsValue, Root, RootEnum}, + xep_0176::{JingleTransport, JingleTransportCandidate, JingleTransportItems}, + xep_0293::{RtcpFb, RtcpFbTrrInt}, + xep_0294::JingleHdrext, + xep_0320::JingleTranportFingerprint, + xep_0338::ContentGroup, + xep_0339::{JingleSsrc, JingleSsrcGroup}, +}; + +#[derive(Serialize, Deserialize, Default)] +pub struct Content { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@creator")] + pub creator: String, + #[serde(rename = "@senders", default)] + pub senders: ContentCreator, + #[serde(rename = "@name")] + pub name: String, + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty", default)] + pub childs: Vec, +} + +impl Content { + pub fn new() -> Self { + Self { + xmlns: "urn:xmpp:jingle:1".to_string(), + creator: "initiator".to_string(), // hardcoded, see https://xmpp.org/extensions/xep-0166.html#table-2 + senders: ContentCreator::default(), + name: String::default(), + childs: Vec::::default(), + } + } + + pub fn add_transport(&mut self, transport: JingleTransport) { + self.childs.push(JingleDes::Transport(transport)); + } +} + +// *** xep-0167 +#[derive(Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ContentCreator { + Initiator, + Responder, + #[default] + Both, +} + +// *** xep-0167 +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum JingleDes { + Description(JingleRtpSessions), + Transport(JingleTransport), + #[serde(other)] + Invalid, +} + +// *** xep-0167 +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum JingleRtpSessionMedia { + Audio, + Application, + Video, +} + +impl JingleRtpSessionMedia { + pub fn new_from_sdp(sdp_media_value: &SdpMediaValue) -> Self { + match sdp_media_value { + SdpMediaValue::Audio => JingleRtpSessionMedia::Audio, + SdpMediaValue::Application => JingleRtpSessionMedia::Application, + SdpMediaValue::Video => JingleRtpSessionMedia::Video, + } + } + + pub fn to_sdp(&self) -> SdpMediaValue { + match self { + JingleRtpSessionMedia::Audio => SdpMediaValue::Audio, + JingleRtpSessionMedia::Application => SdpMediaValue::Application, + JingleRtpSessionMedia::Video => SdpMediaValue::Video, + } + } +} + +// *** xep-0167 +#[derive(Serialize, Deserialize, Default, Clone)] +pub struct JingleRtpSessionsPayloadType { + #[serde(rename = "@id")] + id: u8, + #[serde(rename = "@name", skip_serializing_if = "is_none_or_default")] + name: Option, + #[serde( + rename = "@clockrate", + skip_serializing_if = "is_none_or_default", + default + )] + clockrate: Option, + #[serde(rename = "@channels", skip_serializing_if = "is_none_or_default")] + channels: Option, + #[serde(rename = "@maxptime", skip_serializing_if = "is_none_or_default")] + maxptime: Option, + #[serde(rename = "@ptime", skip_serializing_if = "is_none_or_default")] + ptime: Option, + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty", default)] + parameter: Vec, +} + +macro_rules! add_nondefault_parameter { + ( $self: expr, $params: expr, $name: ident ) => { + if $params.$name != Default::default() { + $self.add_parameter(stringify!($name), $params.$name.to_string()) + } + }; +} + +impl JingleRtpSessionsPayloadType { + pub fn new(id: u8) -> Self { + let mut payload_type = Self::default(); + payload_type.set_id(id); + payload_type + } + + pub fn id(&self) -> u8 { + self.id + } + + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + pub fn parameter(&self) -> &Vec { + &self.parameter + } + + fn set_id(&mut self, id: u8) { + self.id = id; + } + + pub fn fill_from_sdp_rtpmap(&mut self, rtpmap: &SdpAttributeRtpmap) { + self.id = rtpmap.payload_type; + self.name = Some(rtpmap.codec_name.clone()); + self.clockrate = Some(rtpmap.frequency); + self.channels = rtpmap.channels; + } + + pub fn fill_from_sdp_fmtp(&mut self, params: &SdpAttributeFmtpParameters) { + self.maxptime = Some(params.maxptime); + self.ptime = Some(params.ptime); + + add_nondefault_parameter!(self, params, packetization_mode); + add_nondefault_parameter!(self, params, level_asymmetry_allowed); + + add_nondefault_parameter!(self, params, profile_level_id); + // this has a default of 0x420010 which is different to the datatype-default of 0 + // see: https://stackoverflow.com/questions/20634476/is-sprop-parameter-sets-or-profile-level-id-the-sdp-parameter-required-to-decode + if params.profile_level_id != 0x420010 && params.profile_level_id != 0 { + self.add_parameter("profile_level_id", params.profile_level_id.to_string()); + } + add_nondefault_parameter!(self, params, max_fs); + add_nondefault_parameter!(self, params, max_cpb); + add_nondefault_parameter!(self, params, max_dpb); + add_nondefault_parameter!(self, params, max_br); + add_nondefault_parameter!(self, params, max_mbps); + + // VP8 and VP9 + // max_fs, already defined in H264 + add_nondefault_parameter!(self, params, max_fr); + + // Opus https://tools.ietf.org/html/rfc7587 + // this has a default of 48000 which is different to the datatype-default of 0 + if params.maxplaybackrate != 48000 && params.maxplaybackrate != 0 { + self.add_parameter("maxplaybackrate", params.maxplaybackrate.to_string()); + } + add_nondefault_parameter!(self, params, maxaveragebitrate); + add_nondefault_parameter!(self, params, usedtx); + add_nondefault_parameter!(self, params, stereo); + add_nondefault_parameter!(self, params, useinbandfec); + add_nondefault_parameter!(self, params, cbr); + // ptime already set in payload type + add_nondefault_parameter!(self, params, minptime); + // maxptime already set in payload type + + for i in ¶ms.encodings { + self.add_parameter("encodings", i.to_string()); + } + if params.dtmf_tones != String::default() { + self.add_parameter("dtmp_tones", params.dtmf_tones.to_string()); + } + + // rtx + match params.rtx { + None => {} + Some(rtx) => { + self.add_parameter("apt", rtx.apt.to_string()); + match rtx.rtx_time { + None => {} + Some(time) => { + self.add_parameter("rtx-time", time.to_string()); + } + }; + } + }; + + for token in ¶ms.unknown_tokens { + for param in GenericParameter::parse_parameter_string(token) { + if let Some(value) = param.value() { + self.parameter + .push(JingleRtpSessionsPayloadTypeValue::Parameter( + JingleRtpSessionsPayloadTypeParam::new( + param.name().to_string(), + value.to_string(), + ), + )); + } + } + } + } + + pub fn add_parameter(&mut self, name: &str, value: String) { + self.parameter + .push(JingleRtpSessionsPayloadTypeValue::Parameter( + JingleRtpSessionsPayloadTypeParam::new(name.to_string(), value), + )); + } + + pub fn add_rtcp_fb(&mut self, rtcp_fb: RtcpFb) { + self.parameter + .push(JingleRtpSessionsPayloadTypeValue::RtcpFb(rtcp_fb)); + } + + pub fn add_rtcp_fb_trr_int(&mut self, rtcp_fb_trr_int: RtcpFbTrrInt) { + self.parameter + .push(JingleRtpSessionsPayloadTypeValue::RtcpFbTrrInt( + rtcp_fb_trr_int, + )); + } + + fn get_fmtp_param(&self, known_param_names: &mut HashSet, name: &str) -> T + where + T: Default + std::str::FromStr + Clone, + { + match self + .get_fmtp_param_vec::(known_param_names, name) + .first() + { + Some(value) => value.clone(), + _ => T::default(), + } + } + + fn get_fmtp_param_vec(&self, known_param_names: &mut HashSet, name: &str) -> Vec + where + T: std::str::FromStr + Clone, + { + let mut retval: Vec = Vec::new(); + for param in &self.parameter { + match param { + JingleRtpSessionsPayloadTypeValue::Parameter(param) if param.name == name => { + known_param_names.insert(name.to_string()); + let mut value = param.value.clone(); + //bool preprocessing (in xmpp "1" and "true" are defined as true, while "0" and "false are defined as false) + //TODO: implement this for quickxml deserialization, too! + if any::type_name::() == any::type_name::() { + match param.value.to_lowercase().as_str() { + "false" => value.clone_from(&"false".to_string()), + "0" => value.clone_from(&"false".to_string()), + "true" => value.clone_from(&"true".to_string()), + "1" => value.clone_from(&"true".to_string()), + _ => { + panic!("unallowed truth value: {}", value) + } + }; + } + match value.parse::() { + Err(_) => { + eprintln!( + "Error extracting fmtp parameter '{}': wrong type of value '{}'!", + name, param.value + ) + } + Ok(value) => retval.push(value.clone()), + }; + } + _ => {} + } + } + retval + } + + fn get_fmtp_unknown_tokens_vec(&self, known_param_names: &HashSet) -> Vec { + let mut retval: Vec = Vec::new(); + for param in &self.parameter { + match param { + JingleRtpSessionsPayloadTypeValue::Parameter(param) + if !known_param_names.contains(¶m.name) => + { + retval.push(format!("{}={}", param.name, param.value)); + } + _ => {} + } + } + retval + } + + pub fn to_sdp_fmtp(&self) -> Result, SdpParserInternalError> { + // don't return any SdpAttributeFmtp if no attributes are present in xml + // this avoids returning default values for everything, which results in bogus sdp + if self.parameter.is_empty() { + return Ok(None); + } + let mut known_param_names: HashSet = HashSet::new(); + let mut retval = SdpAttributeFmtp { + payload_type: self.id, + parameters: SdpAttributeFmtpParameters { + level_idx: None, + profile: None, + tier: None, + packetization_mode: self + .get_fmtp_param(&mut known_param_names, "packetization_mode"), + level_asymmetry_allowed: self + .get_fmtp_param(&mut known_param_names, "level_asymmetry_allowed"), + // this has a default of 0x420010 which is different to the datatype-default of 0 + // see: https://stackoverflow.com/questions/20634476/is-sprop-parameter-sets-or-profile-level-id-the-sdp-parameter-required-to-decode + profile_level_id: match self + .get_fmtp_param_vec::(&mut known_param_names, "profile_level_id") + .is_empty() + { + true => 0x420010, + false => self.get_fmtp_param(&mut known_param_names, "profile_level_id"), + }, + max_fs: self.get_fmtp_param(&mut known_param_names, "max_fs"), + max_cpb: self.get_fmtp_param(&mut known_param_names, "max_cpb"), + max_dpb: self.get_fmtp_param(&mut known_param_names, "max_dpb"), + max_br: self.get_fmtp_param(&mut known_param_names, "max_br"), + max_mbps: self.get_fmtp_param(&mut known_param_names, "max_mbps"), + max_fr: self.get_fmtp_param(&mut known_param_names, "max_fr"), + // this has a default of 48000 which is different to the datatype-default of 0 + maxplaybackrate: match self + .get_fmtp_param_vec::(&mut known_param_names, "maxplaybackrate") + .is_empty() + { + true => 48000, + false => self.get_fmtp_param(&mut known_param_names, "maxplaybackrate"), + }, + maxaveragebitrate: self.get_fmtp_param(&mut known_param_names, "maxaveragebitrate"), + usedtx: self.get_fmtp_param(&mut known_param_names, "usedtx"), + stereo: self.get_fmtp_param(&mut known_param_names, "stereo"), + useinbandfec: self.get_fmtp_param(&mut known_param_names, "useinbandfec"), + cbr: self.get_fmtp_param(&mut known_param_names, "cbr"), + ptime: self.ptime.unwrap_or_default(), + minptime: self.get_fmtp_param(&mut known_param_names, "minptime"), + maxptime: self.maxptime.unwrap_or_default(), + encodings: self.get_fmtp_param_vec(&mut known_param_names, "encodings"), + dtmf_tones: self.get_fmtp_param(&mut known_param_names, "dtmf_tones"), + // use get_fmtp_param_vec() to search for existence because get_fmtp_param() does not return an Option() but a default value for T + rtx: match self + .get_fmtp_param_vec::(&mut known_param_names, "apt") + .is_empty() + { + true => None, + false => Some(RtxFmtpParameters { + apt: self.get_fmtp_param::(&mut known_param_names, "apt"), + rtx_time: match self + .get_fmtp_param_vec::(&mut known_param_names, "rtx-time") + .is_empty() + { + true => None, + false => { + Some(self.get_fmtp_param::(&mut known_param_names, "rtx-time")) + } + }, + }), + }, + unknown_tokens: Vec::new(), + }, + }; + retval.parameters.unknown_tokens = self.get_fmtp_unknown_tokens_vec(&known_param_names); + Ok(Some(retval)) + } + + pub fn to_sdp_rtpmap(&self) -> SdpAttributeRtpmap { + SdpAttributeRtpmap { + payload_type: self.id, + codec_name: match &self.name { + Some(name) => name.to_string(), + None => "".to_string(), + }, + frequency: self.clockrate.unwrap_or_default(), + channels: self.channels, + } + } +} + +// *** xep-0167 +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum JingleRtpSessionsPayloadTypeValue { + Parameter(JingleRtpSessionsPayloadTypeParam), + RtcpFbTrrInt(RtcpFbTrrInt), + RtcpFb(RtcpFb), + #[serde(other)] + Invalid, +} + +// *** xep-0167 +#[derive(Serialize, Deserialize, Clone)] +pub struct JingleRtpSessionsPayloadTypeParam { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "@value")] + value: String, +} + +impl JingleRtpSessionsPayloadTypeParam { + pub fn new(name: String, value: String) -> Self { + Self { name, value } + } +} + +// *** xep-0167 +#[derive(Serialize, Deserialize, Clone)] +pub struct JingleRtpSessionsBandwidth { + #[serde(rename = "type")] + bwtype: String, + value: u32, // TODO: should be u128 +} + +impl JingleRtpSessionsBandwidth { + pub fn new_from_sdp(sdp_bandwidth: &SdpBandwidth) -> Self { + let (bwtype, value) = match sdp_bandwidth { + SdpBandwidth::As(value) => ("AS".to_string(), value), + SdpBandwidth::Ct(value) => ("CT".to_string(), value), + SdpBandwidth::Tias(value) => ("TIAS".to_string(), value), + SdpBandwidth::Unknown(bwtype, value) => (bwtype.to_string(), value), + }; + Self { + bwtype, + value: *value, + } + } + + pub fn to_sdp(&self) -> SdpBandwidth { + match self.bwtype.to_ascii_uppercase().as_str() { + "AS" => SdpBandwidth::As(self.value), + "CT" => SdpBandwidth::Ct(self.value), + "TIAS" => SdpBandwidth::Tias(self.value), + _ => SdpBandwidth::Unknown(self.bwtype.to_string(), self.value), + } + } +} + +// *** xep-0167 +#[derive(Serialize, Deserialize)] +pub struct JingleRtpSessions { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@media")] + media: JingleRtpSessionMedia, + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty", default)] + item: Vec, + #[serde(skip)] + payload_helper: HashMap, + #[serde(skip)] + ssrc_helper: HashMap, +} + +impl JingleRtpSessions { + fn new(sdp_media: &SdpMediaValue) -> Self { + Self { + xmlns: "urn:xmpp:jingle:apps:rtp:1".to_string(), + media: JingleRtpSessionMedia::new_from_sdp(sdp_media), + item: Vec::new(), + payload_helper: HashMap::new(), + ssrc_helper: HashMap::new(), + } + } + + pub fn item(&self) -> &Vec { + &self.item + } + + pub fn media(&self) -> &JingleRtpSessionMedia { + &self.media + } + + pub fn fill_from_payload_helper(&mut self) { + for (_, payload) in self.payload_helper.iter() { + self.item + .push(JingleRtpSessionsValue::PayloadType(payload.clone())); + } + } + + fn get_payload_type(&mut self, id: u8) -> &mut JingleRtpSessionsPayloadType { + let p_t = self + .payload_helper + .entry(id) + .or_insert(JingleRtpSessionsPayloadType::new(id)); + p_t + } + + pub fn fill_from_ssrc_helper(&mut self) { + for (_, ssrc) in self.ssrc_helper.iter() { + self.item.push(JingleRtpSessionsValue::Source(ssrc.clone())); + } + } + + fn get_ssrc(&mut self, id: u32) -> &mut JingleSsrc { + let ssrc = self.ssrc_helper.entry(id).or_insert(JingleSsrc::new(id)); + ssrc + } + + pub fn add_ssrc(&mut self, ssrc: &SdpAttributeSsrc) { + let jingle_ssrc = self.get_ssrc(ssrc.id); + if let Some(attribute) = &ssrc.attribute { + match &ssrc.value { + Some(value) => jingle_ssrc.add_parameter(attribute, Some(value.to_string())), + _ => jingle_ssrc.add_parameter(attribute, None), + } + } + } + + pub fn add_ssrc_group(&mut self, ssrc_group: JingleSsrcGroup) { + self.item + .push(JingleRtpSessionsValue::SsrcGroup(ssrc_group)); + } + + pub fn add_extmap_allow_mixed(&mut self) { + self.item.push(JingleRtpSessionsValue::ExtmapAllowMixed); + } + + pub fn add_extmap_hdrext(&mut self, hdrext: JingleHdrext) { + self.item.push(JingleRtpSessionsValue::RtpHdrext(hdrext)); + } + + pub fn add_sdp_bandwith(&mut self, jingle_bandwith: JingleRtpSessionsBandwidth) { + self.item + .push(JingleRtpSessionsValue::Bandwidth(jingle_bandwith)); + } + + pub fn add_rtcp_mux(&mut self) { + self.item.push(JingleRtpSessionsValue::RtcpMux); + } + + pub fn add_rtcp_fb(&mut self, rtcp_fb: RtcpFb) { + self.item.push(JingleRtpSessionsValue::RtcpFb(rtcp_fb)); + } + + pub fn add_rtcp_fb_trr_int(&mut self, rtcp_fb_trr_int: RtcpFbTrrInt) { + self.item + .push(JingleRtpSessionsValue::RtcpFbTrrInt(rtcp_fb_trr_int)); + } + + pub fn from_sdp(sdp: &SdpSession, initiator: bool) -> Result { + let mut root = Root::default(); + + let mut has_global_extmap_allow_mixed: bool = false; //translate global ExtmapAllowMixed to media-local ExtmapAllowMixed values + for attribute in &sdp.attribute { + match attribute { + SdpAttribute::Group(group) => { + root.push(RootEnum::Group(ContentGroup::new_from_sdp(group))); + } + SdpAttribute::ExtmapAllowMixed => { + has_global_extmap_allow_mixed = true; + } + _ => {} //ignore all other global attributes + } + } + + for media in &sdp.media { + let mut content: Content = Content::new(); + let mut jingle: JingleRtpSessions = Self::new(media.get_type()); + if has_global_extmap_allow_mixed { + jingle.add_extmap_allow_mixed(); + } + + let mut attribute_map: HashMap> = HashMap::new(); + let format_ids = match media.get_formats() { + SdpFormatList::Strings(_) => { + // Ignore data-channel + continue; + } + SdpFormatList::Integers(formats) => formats, + }; + for format in format_ids { + attribute_map.insert(*format as u8, Vec::new()); + } + + let mut media_transport = JingleTransport::new(); + let mut fingerprint = JingleTranportFingerprint::new(); + for attribute in media.get_attributes() { + match attribute { + SdpAttribute::BundleOnly => {} + SdpAttribute::Candidate(candidate) => { + media_transport + .add_candidate(JingleTransportCandidate::new_from_sdp(candidate)?); + //use ufrag from candidate if not (yet) set by dedicated ufrag attribute + //(may be be overwritten if we encounter a dedicated ufrag attribute later on) + if media_transport.get_ufrag().is_none() { + if let Some(ufrag) = &candidate.ufrag { + media_transport.set_ufrag(ufrag.clone()); + } + } + } + SdpAttribute::DtlsMessage(_) => {} + SdpAttribute::EndOfCandidates => {} + SdpAttribute::Extmap(hdrext) => { + jingle.add_extmap_hdrext(JingleHdrext::new_from_sdp(initiator, hdrext)); + } + SdpAttribute::ExtmapAllowMixed => { + if !has_global_extmap_allow_mixed { + jingle.add_extmap_allow_mixed(); + } + } + SdpAttribute::Fingerprint(f) => { + fingerprint.set_fingerprint(f); + } + SdpAttribute::Fmtp(fmtp) => { + jingle + .get_payload_type(fmtp.payload_type) + .fill_from_sdp_fmtp(&fmtp.parameters); + } + SdpAttribute::Group(_) => {} + SdpAttribute::IceLite => {} + SdpAttribute::IceMismatch => {} + SdpAttribute::IceOptions(options) => { + // hardcoded by tribal knowledge to "trickle", but we want to negotiate other options, too + // see https://codeberg.org/iNPUTmice/Conversations/commit/fd4b8ba1885a9f6e24a87e47c3a6a730f9ed15f8 + for option in options { + media_transport.add_ice_option(option); + } + } + SdpAttribute::IcePacing(_) => {} + SdpAttribute::IcePwd(s) => { + media_transport.set_pwd(s.clone()); + } + SdpAttribute::IceUfrag(s) => { + media_transport.set_ufrag(s.clone()); + } + SdpAttribute::Identity(_) => {} + SdpAttribute::ImageAttr(_) => {} + SdpAttribute::Inactive => {} + SdpAttribute::Label(_) => {} + SdpAttribute::MaxMessageSize(_) => {} + SdpAttribute::MaxPtime(_) => {} + SdpAttribute::Mid(name) => { + content.name.clone_from(name); + } + SdpAttribute::Msid(_) => {} + SdpAttribute::MsidSemantic(_) => {} + SdpAttribute::Ptime(_) => {} + SdpAttribute::Rid(_) => {} + SdpAttribute::Recvonly => { + if initiator { + content.senders = ContentCreator::Responder; + } else { + content.senders = ContentCreator::Initiator; + } + } + SdpAttribute::RemoteCandidate(_) => {} + SdpAttribute::Rtpmap(rtmap) => { + jingle + .get_payload_type(rtmap.payload_type) + .fill_from_sdp_rtpmap(rtmap); + } + SdpAttribute::Rtcp(_) => {} + SdpAttribute::Rtcpfb(fb) => { + // TODO use trait later + match fb.payload_type { + SdpAttributePayloadType::Wildcard => match fb.feedback_type { + SdpAttributeRtcpFbType::TrrInt => { + jingle.add_rtcp_fb_trr_int(RtcpFbTrrInt::new_from_sdp(fb)); + } + _ => { + jingle.add_rtcp_fb(RtcpFb::new_from_sdp(fb)); + } + }, + SdpAttributePayloadType::PayloadType(payload_id) => { + let jingle_p_t: &mut JingleRtpSessionsPayloadType = + jingle.get_payload_type(payload_id); + match fb.feedback_type { + SdpAttributeRtcpFbType::TrrInt => { + jingle_p_t + .add_rtcp_fb_trr_int(RtcpFbTrrInt::new_from_sdp(fb)); + } + _ => { + jingle_p_t.add_rtcp_fb(RtcpFb::new_from_sdp(fb)); + } + } + } + } + } + SdpAttribute::RtcpMux => { + jingle.add_rtcp_mux(); + } + SdpAttribute::RtcpMuxOnly => {} + SdpAttribute::RtcpRsize => {} + SdpAttribute::Sctpmap(_) => {} + SdpAttribute::SctpPort(_) => {} + SdpAttribute::Sendonly => { + if initiator { + content.senders = ContentCreator::Initiator; + } else { + content.senders = ContentCreator::Responder; + } + } + SdpAttribute::Sendrecv => { + content.senders = ContentCreator::Both; + } + SdpAttribute::Setup(s) => { + fingerprint.set_setup(s); + } + SdpAttribute::Simulcast(_) => {} + SdpAttribute::Ssrc(ssrc) => { + jingle.add_ssrc(ssrc); + } + SdpAttribute::SsrcGroup(semantics, ssrcs) => { + jingle.add_ssrc_group(JingleSsrcGroup::new_from_sdp(semantics, ssrcs)); + } + SdpAttribute::FrameRate(_) => {} + } + } + if fingerprint.is_set() { + media_transport.add_fingerprint(fingerprint); + } + content.add_transport(media_transport); + for sdp_bandwidth in media.get_bandwidth() { + let jingle_bandwith = JingleRtpSessionsBandwidth::new_from_sdp(sdp_bandwidth); + jingle.add_sdp_bandwith(jingle_bandwith.clone()); + } + jingle.fill_from_payload_helper(); + jingle.fill_from_ssrc_helper(); + content.childs.push(JingleDes::Description(jingle)); + root.push(RootEnum::Content(content)); + } + Ok(root) + } + + pub fn to_sdp(root: &Root, initiator: bool) -> Result { + let sdp_origin = SdpOrigin { + //TODO: really hardcode these?? + username: "-".to_string(), + session_id: 2005859539484728435, + session_version: 2, + unicast_addr: ExplicitlyTypedAddress::Ip(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)), + }; + let mut sdp = SdpSession::new(0, sdp_origin, "-".to_string()); + // timing values are always zero when the offer/answer model is used + sdp.set_timing(SdpTiming { start: 0, stop: 0 }); + + for root_entry in root.childs() { + match root_entry { + RootEnum::Group(jingle_group) => { + if let Err(e) = sdp.add_attribute(SdpAttribute::Group(jingle_group.to_sdp())) { + eprintln!("Could not add ContentGroup attribute to sdp: {}", e); + return Err(e); + } else { + //hardcoded because webrtc needs this but there is no xep for it! + //only add this if we use xep-0338 content groups + sdp.add_attribute(SdpAttribute::MsidSemantic(SdpAttributeMsidSemantic { + semantic: " WMS".to_string(), + msids: vec!["stream".to_string()], + }))?; + } + } + RootEnum::Content(jingle_content) => { + // first of all: create media element... + let mut media_type: Option = None; + for child in &jingle_content.childs { + match child { + JingleDes::Transport(_) => {} + JingleDes::Description(rtp_session) => { + media_type = Some(rtp_session.media().to_sdp()); + } + JingleDes::Invalid => continue, + } + } + let direction = match jingle_content.senders { + ContentCreator::Initiator => { + if initiator { + SdpAttribute::Sendonly + } else { + SdpAttribute::Recvonly + } + } + ContentCreator::Responder => { + if initiator { + SdpAttribute::Recvonly + } else { + SdpAttribute::Sendonly + } + } + ContentCreator::Both => SdpAttribute::Sendrecv, + }; + if let Some(media_type) = media_type { + let mut media = SdpMedia::new(SdpMediaLine { + media: media_type, + port: 9, //port hardcoded by xep? + //hardcoded by xep? see also https://codeberg.org/iNPUTmice/Conversations/src/branch/master/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java#L28 + port_count: 0, // hardcoded + proto: SdpProtocolValue::UdpTlsRtpSavp, + formats: SdpFormatList::Integers(Vec::new()), + }); + + if let Err(e) = media.add_attribute(direction) { + eprintln!("Could not add Media to sdp: {}", e); + return Result::Err(e); + } + + media.set_connection(SdpConnection { + address: ExplicitlyTypedAddress::Ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + ttl: None, + amount: None, + }); + + sdp.media.push(media); + } else { + return Err(SdpParserInternalError::Generic( + "No media found in jingle!".to_string(), + )); + } + let media = sdp.media.last_mut().unwrap(); + let mut ice_options: Vec = Vec::new(); + + // ...after that: fill media attributes + media.add_attribute(SdpAttribute::Mid(jingle_content.name.clone()))?; + for child in &jingle_content.childs { + match child { + JingleDes::Transport(transport) => { + // hardcoded by xep-0293 ?? + media.add_attribute(SdpAttribute::Rtcp(SdpAttributeRtcp { + port: 9, + unicast_addr: Some(ExplicitlyTypedAddress::Ip(IpAddr::V4( + Ipv4Addr::UNSPECIFIED, + ))), + }))?; + + if let Some(pwd) = transport.get_pwd() { + media.add_attribute(SdpAttribute::IcePwd(pwd))?; + } + if let Some(ufrag) = transport.get_ufrag() { + media.add_attribute(SdpAttribute::IceUfrag(ufrag))?; + } + for item in transport.items() { + match item { + JingleTransportItems::Fingerprint(fingerprint) => { + media.add_attribute(SdpAttribute::Fingerprint( + fingerprint.get_fingerprint()?, + ))?; + media.add_attribute(SdpAttribute::Setup( + fingerprint.get_setup(), + ))?; + } + JingleTransportItems::Candidate(candidate) => { + media.add_attribute(SdpAttribute::Candidate( + candidate.to_sdp(transport.get_ufrag())?, + ))?; + } + JingleTransportItems::Trickle(_) => { + ice_options.push("trickle".to_string()); + } + JingleTransportItems::Renomination(_) => { + ice_options.push("renomination".to_string()); + } + JingleTransportItems::Invalid => {} + } + } + } + JingleDes::Description(rtp_session) => { + for session_value in rtp_session.item() { + if let Err(e) = match session_value { + JingleRtpSessionsValue::PayloadType(payload_type) => { + if let Err(e) = + media.add_codec(payload_type.to_sdp_rtpmap()) + { + eprintln!( + "Could not add media codec {} ({}) to sdp: {}", + payload_type.id(), + match &payload_type.name() { + Some(name) => name, + None => "", + }, + e + ); + return Err(e); + } + + for param in payload_type.parameter() { + match ¶m { + JingleRtpSessionsPayloadTypeValue::Parameter(_) => (), // will be handled by to_sdp_fmtp() below + JingleRtpSessionsPayloadTypeValue::RtcpFb(fb) => { + media.add_attribute(SdpAttribute::Rtcpfb(fb.to_sdp(SdpAttributePayloadType::PayloadType(payload_type.id()))))?; + }, + JingleRtpSessionsPayloadTypeValue::RtcpFbTrrInt(trr_int) => { + media.add_attribute(SdpAttribute::Rtcpfb(trr_int.to_sdp(SdpAttributePayloadType::PayloadType(payload_type.id()))))?; + }, + JingleRtpSessionsPayloadTypeValue::Invalid => continue, + } + } + + if let Some(fmtp) = payload_type.to_sdp_fmtp()? { + media.add_attribute(SdpAttribute::Fmtp(fmtp))? + } + + Ok(()) + } + JingleRtpSessionsValue::Bandwidth(bandwidth) => { + media.add_bandwidth(bandwidth.to_sdp()); + Ok(()) + } + JingleRtpSessionsValue::RtcpMux => { + media.add_attribute(SdpAttribute::RtcpMux) + } + JingleRtpSessionsValue::RtcpFb(fb) => { + media.add_attribute(SdpAttribute::Rtcpfb( + fb.to_sdp(SdpAttributePayloadType::Wildcard), + )) + } + JingleRtpSessionsValue::RtcpFbTrrInt(trr_int) => media + .add_attribute(SdpAttribute::Rtcpfb( + trr_int.to_sdp(SdpAttributePayloadType::Wildcard), + )), + JingleRtpSessionsValue::Source(ssrc) => { + for attribute in ssrc.to_sdp() { + if let Err(e) = media + .add_attribute(SdpAttribute::Ssrc(attribute)) + { + eprintln!( + "Could not add Ssrc attribute to sdp: {}", + e + ); + return Err(e); + } + } + Ok(()) + } + JingleRtpSessionsValue::SsrcGroup(ssrc_group) => { + let (semantics, ssrcs) = ssrc_group.to_sdp(); + media.add_attribute(SdpAttribute::SsrcGroup( + semantics, ssrcs, + )) + } + JingleRtpSessionsValue::ExtmapAllowMixed => { + media.add_attribute(SdpAttribute::ExtmapAllowMixed) + } + JingleRtpSessionsValue::RtpHdrext(hdrext) => media + .add_attribute(SdpAttribute::Extmap( + hdrext.to_sdp(initiator), + )), + JingleRtpSessionsValue::Invalid => Ok(()), + } { + eprintln!("Could not add attribute to sdp: {}", e); + return Err(e); + } + } + } + JingleDes::Invalid => continue, + } + } + if ice_options.is_empty() { + // hardcoded by tribal knowledge to "trickle" + media.add_attribute(SdpAttribute::IceOptions( + ["trickle"].iter().map(|s| s.to_string()).collect(), + ))?; + } else { + // use xep-gultsch (see https://codeberg.org/iNPUTmice/Conversations/commit/fd4b8ba1885a9f6e24a87e47c3a6a730f9ed15f8) + media.add_attribute(SdpAttribute::IceOptions( + ice_options.iter().map(|s| s.to_string()).collect(), + ))?; + } + } + RootEnum::Invalid => {} + } + } + Ok(sdp) + } +} diff --git a/rust/sdp-to-jingle/src/xep_0176.rs b/rust/sdp-to-jingle/src/xep_0176.rs new file mode 100644 index 0000000..8c0699c --- /dev/null +++ b/rust/sdp-to-jingle/src/xep_0176.rs @@ -0,0 +1,251 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_derive::{Deserialize, Serialize}; +use webrtc_sdp::{ + address::Address, + attribute_type::{ + SdpAttributeCandidate, SdpAttributeCandidateTransport, SdpAttributeCandidateType, + }, + error::SdpParserInternalError, +}; + +use crate::xep_0320::JingleTranportFingerprint; + +// *** xep-0176 +#[derive(Serialize, Deserialize, Default)] +pub struct JingleTransport { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@pwd", skip_serializing_if = "Option::is_none")] + pwd: Option, + #[serde(rename = "@ufrag", skip_serializing_if = "Option::is_none")] + ufrag: Option, + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty", default)] + items: Vec, +} + +impl JingleTransport { + pub fn new() -> Self { + Self { + xmlns: "urn:xmpp:jingle:transports:ice-udp:1".to_string(), + ..Default::default() + } + } + + pub fn get_pwd(&self) -> Option { + self.pwd.clone() + } + + pub fn set_pwd(&mut self, pwd: String) { + self.pwd = Some(pwd); + } + + pub fn get_ufrag(&self) -> Option { + self.ufrag.clone() + } + + pub fn set_ufrag(&mut self, ufrag: String) { + self.ufrag = Some(ufrag); + } + + pub fn add_fingerprint(&mut self, fingerprint: JingleTranportFingerprint) { + self.items + .push(JingleTransportItems::Fingerprint(fingerprint)); + } + + // see https://codeberg.org/iNPUTmice/Conversations/commit/fd4b8ba1885a9f6e24a87e47c3a6a730f9ed15f8 + pub fn add_ice_option(&mut self, option: &String) { + match option.as_str() { + "trickle" => { + self.items + .push(JingleTransportItems::Trickle(JingleICEOptionTrickle { + xmlns: gultsch_ice_options_ns(), + })); + } + "renomination" => { + self.items.push(JingleTransportItems::Renomination( + JingleICEOptionRenomination { + xmlns: gultsch_ice_options_ns(), + }, + )); + } + &_ => { + eprintln!("*** Encountered unknown ice option: {}", option); + } + } + } + + pub fn add_candidate(&mut self, candidate: JingleTransportCandidate) { + self.items.push(JingleTransportItems::Candidate(candidate)); + } + + pub fn items(&self) -> &Vec { + &self.items + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum JingleTransportItems { + Fingerprint(JingleTranportFingerprint), + Candidate(JingleTransportCandidate), + Trickle(JingleICEOptionTrickle), + Renomination(JingleICEOptionRenomination), + #[serde(other)] + Invalid, +} + +//the next two structs are only needed because quick-xml does not support xml namespaces + +fn gultsch_ice_options_ns() -> String { + "http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option".to_string() +} + +// *** xep-gultsch (see https://codeberg.org/iNPUTmice/Conversations/commit/fd4b8ba1885a9f6e24a87e47c3a6a730f9ed15f8) +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct JingleICEOptionTrickle { + #[serde(rename = "@xmlns", default = "gultsch_ice_options_ns")] + xmlns: String, +} + +// *** xep-gultsch (see https://codeberg.org/iNPUTmice/Conversations/commit/fd4b8ba1885a9f6e24a87e47c3a6a730f9ed15f8) +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct JingleICEOptionRenomination { + #[serde(rename = "@xmlns", default = "gultsch_ice_options_ns")] + xmlns: String, +} + +// *** xep-0176 +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct JingleTransportCandidate { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@id")] + id: Option, // not given in sdp, entirely made up random value, this is required according to xep, but some clients don't set it + #[serde(rename = "@component")] + component: u32, + #[serde(rename = "@foundation")] + foundation: String, + #[serde(rename = "@generation")] + generation: u32, + #[serde(rename = "@ip")] + ip: String, // address: Address + #[serde(rename = "@network", skip_serializing_if = "Option::is_none")] + network: Option, + #[serde(rename = "@port")] + port: u32, + #[serde(rename = "@priority")] + priority: u64, + #[serde(rename = "@protocol")] + protocol: String, // transport: SdpAttributeCandidateTransport + #[serde(rename = "@rel-addr", skip_serializing_if = "Option::is_none")] + raddr: Option, // raddr: Option
+ #[serde(rename = "@rel_port", skip_serializing_if = "Option::is_none")] + rport: Option, + #[serde(rename = "@type")] + c_type: JingleTransportCandidateType, +} + +impl JingleTransportCandidate { + pub fn new_from_sdp(candidate: &SdpAttributeCandidate) -> Result { + let id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + Ok(Self { + xmlns: "urn:xmpp:jingle:transports:ice-udp:1".to_string(), + id: Some(format!("{}", id)), + component: candidate.component, + foundation: candidate.foundation.to_string(), + generation: match candidate.generation { + None => { + eprintln!("Faking a '0' for unspecified candidate generation!"); + 0 //xep-0176 says this is required, fake a 0 value + } + Some(generation) => generation, + }, + ip: format!("{}", candidate.address), + network: None, //hardcoded to None + port: candidate.port, + priority: candidate.priority, + protocol: match candidate.transport { + SdpAttributeCandidateTransport::Udp => "udp".to_string(), + SdpAttributeCandidateTransport::Tcp => "tcp".to_string(), //not specced in xep-0176 + }, + raddr: candidate.raddr.as_ref().map(|addr| format!("{}", addr)), + rport: candidate.rport, + c_type: JingleTransportCandidateType::new_from_sdp(&candidate.c_type), + }) + } + + pub fn to_sdp( + &self, + ufrag: Option, + ) -> Result { + Ok(SdpAttributeCandidate { + foundation: self.foundation.to_string(), + component: self.component, + transport: match self.protocol.as_str() { + "udp" => Ok(SdpAttributeCandidateTransport::Udp), + "tcp" => Ok(SdpAttributeCandidateTransport::Tcp), //not specced in xep-0176 + _ => Err(SdpParserInternalError::Generic( + "Encountered some candidate transport (like tcp) not specced in XEP-0176!" + .to_string(), + )), + }?, + priority: self.priority, + address: match &self.ip.parse() { + Ok(ip) => Address::Ip(*ip), + Err(_) => Address::Fqdn(self.ip.to_string()), + }, + port: self.port, + c_type: self.c_type.to_sdp(), + raddr: match &self.raddr { + None => None, + Some(addr) => match &addr.parse() { + Ok(ip) => Some(Address::Ip(*ip)), + Err(_) => Some(Address::Fqdn(addr.to_string())), + }, + }, + rport: self.rport, + tcp_type: None, //tcp transport is not specced in any xep + generation: Some(self.generation), + ufrag, + networkcost: None, //not specced in xep-0176 + unknown_extensions: Vec::new(), //not specced in xep-0176 + }) + } +} + +// *** xep-0176 +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum JingleTransportCandidateType { + Host, + Srflx, + Prflx, + Relay, +} + +impl JingleTransportCandidateType { + pub fn new_from_sdp(c_type: &SdpAttributeCandidateType) -> Self { + match c_type { + SdpAttributeCandidateType::Host => Self::Host, + SdpAttributeCandidateType::Srflx => Self::Srflx, + SdpAttributeCandidateType::Prflx => Self::Prflx, + SdpAttributeCandidateType::Relay => Self::Relay, + } + } + + pub fn to_sdp(&self) -> SdpAttributeCandidateType { + match self { + Self::Host => SdpAttributeCandidateType::Host, + Self::Srflx => SdpAttributeCandidateType::Srflx, + Self::Prflx => SdpAttributeCandidateType::Prflx, + Self::Relay => SdpAttributeCandidateType::Relay, + } + } +} diff --git a/rust/sdp-to-jingle/src/xep_0293.rs b/rust/sdp-to-jingle/src/xep_0293.rs new file mode 100644 index 0000000..ec9dc9e --- /dev/null +++ b/rust/sdp-to-jingle/src/xep_0293.rs @@ -0,0 +1,127 @@ +use serde_derive::{Deserialize, Serialize}; +use webrtc_sdp::attribute_type::{ + SdpAttributePayloadType, SdpAttributeRtcpFb, SdpAttributeRtcpFbType, +}; + +use crate::jingle::{GenericParameter, GenericParameterEnum}; + +// *** xep-0293 +#[derive(Serialize, Deserialize, Clone)] +pub struct RtcpFb { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@type")] + fb_type: RtcpFbType, + #[serde(rename = "@subtype", skip_serializing_if = "Option::is_none")] + subtype: Option, + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty", default)] + parameter: Vec, +} + +impl RtcpFb { + pub fn new_from_sdp(sdp: &SdpAttributeRtcpFb) -> Self { + assert!(!matches!(sdp.feedback_type, SdpAttributeRtcpFbType::TrrInt)); + Self { + xmlns: "urn:xmpp:jingle:apps:rtp:rtcp-fb:0".to_string(), + fb_type: RtcpFbType::new_from_sdp(&sdp.feedback_type), + subtype: if sdp.parameter.is_empty() { + None + } else { + Some(sdp.parameter.clone()) + }, + parameter: GenericParameter::parse_parameter_string(&sdp.extra) + .into_iter() + .map(GenericParameterEnum::Parameter) + .collect::>(), + } + } + + pub fn to_sdp(&self, payload_type: SdpAttributePayloadType) -> SdpAttributeRtcpFb { + SdpAttributeRtcpFb { + payload_type, + feedback_type: self.fb_type.to_sdp(), + parameter: match &self.subtype { + Some(subtype) => subtype.to_string(), + None => "".to_string(), + }, + extra: GenericParameter::create_parameter_string( + &self + .parameter + .clone() + .into_iter() + .filter_map(|p| match p { + GenericParameterEnum::Parameter(p) => Some(p), + GenericParameterEnum::Invalid => None, + }) + .collect::>(), + ), + } + } +} + +// *** xep-0293 +#[derive(Serialize, Deserialize, Clone)] +pub struct RtcpFbTrrInt { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@value", default)] + value: u32, +} + +impl RtcpFbTrrInt { + pub fn new_from_sdp(sdp: &SdpAttributeRtcpFb) -> Self { + assert!(matches!(sdp.feedback_type, SdpAttributeRtcpFbType::TrrInt)); + Self { + xmlns: "urn:xmpp:jingle:apps:rtp:rtcp-fb:0".to_string(), + value: sdp.parameter.parse().unwrap_or_default(), + } + } + + pub fn to_sdp(&self, payload_type: SdpAttributePayloadType) -> SdpAttributeRtcpFb { + SdpAttributeRtcpFb { + payload_type, + feedback_type: SdpAttributeRtcpFbType::TrrInt, + parameter: self.value.to_string(), + extra: "".to_string(), + } + } +} + +// *** xep-0293 +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum RtcpFbType { + Ack, + Ccm, + Nack, + TrrInt, + //the following two don't seem to be registered at IANA: https://www.iana.org/assignments/sdp-parameters/sdp-parameters.xhtml#sdp-parameters-14 + #[serde(rename = "goog-remb")] + //this is defined in https://datatracker.ietf.org/doc/html/draft-alvestrand-rmcat-remb-03 + Remb, + TransportCc, +} + +impl RtcpFbType { + pub fn new_from_sdp(sdp: &SdpAttributeRtcpFbType) -> Self { + match sdp { + SdpAttributeRtcpFbType::Ack => Self::Ack, + SdpAttributeRtcpFbType::Ccm => Self::Ccm, + SdpAttributeRtcpFbType::Nack => Self::Nack, + SdpAttributeRtcpFbType::TrrInt => Self::TrrInt, + SdpAttributeRtcpFbType::Remb => Self::Remb, + SdpAttributeRtcpFbType::TransCc => Self::TransportCc, + } + } + + pub fn to_sdp(&self) -> SdpAttributeRtcpFbType { + match self { + Self::Ack => SdpAttributeRtcpFbType::Ack, + Self::Ccm => SdpAttributeRtcpFbType::Ccm, + Self::Nack => SdpAttributeRtcpFbType::Nack, + Self::TrrInt => SdpAttributeRtcpFbType::TrrInt, + Self::Remb => SdpAttributeRtcpFbType::Remb, + Self::TransportCc => SdpAttributeRtcpFbType::TransCc, + } + } +} diff --git a/rust/sdp-to-jingle/src/xep_0294.rs b/rust/sdp-to-jingle/src/xep_0294.rs new file mode 100644 index 0000000..cb989aa --- /dev/null +++ b/rust/sdp-to-jingle/src/xep_0294.rs @@ -0,0 +1,98 @@ +use serde_derive::{Deserialize, Serialize}; +use webrtc_sdp::attribute_type::{SdpAttributeDirection, SdpAttributeExtmap}; + +use crate::{ + jingle::{GenericParameter, GenericParameterEnum}, + xep_0167::ContentCreator, +}; + +// *** xep-0294 +#[derive(Serialize, Deserialize)] +pub struct JingleHdrext { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@id")] + id: u16, + #[serde(rename = "@uri")] + uri: String, + #[serde(rename = "@senders", skip_serializing_if = "Option::is_none")] + senders: Option, + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty", default)] + parameter: Vec, +} + +impl JingleHdrext { + pub fn new_from_sdp(initiator: bool, entry: &SdpAttributeExtmap) -> Self { + let parameter_vec: Vec; + if let Some(attributes) = &entry.extension_attributes { + parameter_vec = GenericParameter::parse_parameter_string(attributes) + .into_iter() + .map(GenericParameterEnum::Parameter) + .collect::>() + } else { + parameter_vec = Vec::new(); + } + Self { + xmlns: "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0".to_string(), + id: entry.id, + uri: entry.url.to_string(), + senders: entry.direction.as_ref().map(|direction| match direction { + SdpAttributeDirection::Recvonly => { + if initiator { + ContentCreator::Responder + } else { + ContentCreator::Initiator + } + } + SdpAttributeDirection::Sendonly => { + if initiator { + ContentCreator::Initiator + } else { + ContentCreator::Responder + } + } + SdpAttributeDirection::Sendrecv => ContentCreator::Both, + }), + parameter: parameter_vec, + } + } + + pub fn to_sdp(&self, initiator: bool) -> SdpAttributeExtmap { + SdpAttributeExtmap { + id: self.id, + url: self.uri.clone(), + direction: self.senders.as_ref().map(|direction| match direction { + ContentCreator::Initiator => { + if initiator { + SdpAttributeDirection::Sendonly + } else { + SdpAttributeDirection::Recvonly + } + } + ContentCreator::Responder => { + if initiator { + SdpAttributeDirection::Recvonly + } else { + SdpAttributeDirection::Sendonly + } + } + ContentCreator::Both => SdpAttributeDirection::Sendrecv, + }), + extension_attributes: if self.parameter.is_empty() { + None + } else { + Some(GenericParameter::create_parameter_string( + &self + .parameter + .clone() + .into_iter() + .filter_map(|p| match p { + GenericParameterEnum::Parameter(p) => Some(p), + GenericParameterEnum::Invalid => None, + }) + .collect::>(), + )) + }, + } + } +} diff --git a/rust/sdp-to-jingle/src/xep_0320.rs b/rust/sdp-to-jingle/src/xep_0320.rs new file mode 100644 index 0000000..4bdd5ef --- /dev/null +++ b/rust/sdp-to-jingle/src/xep_0320.rs @@ -0,0 +1,103 @@ +use serde_derive::{Deserialize, Serialize}; +use webrtc_sdp::{ + attribute_type::{SdpAttributeFingerprint, SdpAttributeFingerprintHashType, SdpAttributeSetup}, + error::SdpParserInternalError, +}; + +// *** xep-0320 +#[derive(Serialize, Deserialize, Default)] +pub struct JingleTranportFingerprint { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@hash")] + hash: String, + #[serde(rename = "@setup")] + setup: JingleTranportFingerprintSetup, + #[serde(rename = "$value")] + fingerprint: String, + #[serde(skip)] + is_set: bool, //used only for sdp2jingle, always false for jingle2sdp +} + +impl JingleTranportFingerprint { + pub fn new() -> Self { + Self { + xmlns: "urn:xmpp:jingle:apps:dtls:0".to_string(), + ..Default::default() + } + } + + pub fn set_fingerprint(&mut self, sdp_a_fingerprint: &SdpAttributeFingerprint) { + self.hash = sdp_a_fingerprint.hash_algorithm.to_string(); + self.fingerprint = sdp_a_fingerprint + .fingerprint + .iter() + .map(|byte| format!("{:02X}", byte)) + .collect::>() + .join(":"); + self.is_set = true; + } + + pub fn get_fingerprint(&self) -> Result { + // could also use this fn body, but that would tie the fingerprint format in sdp and jingle together: + // let hash_algorithm = SdpAttributeFingerprintHashType::try_from_name(self.hash.as_str())?; + // let bytes = hash_algorithm.parse_octets(self.fingerprint.as_str())?; + // SdpAttributeFingerprint::try_from((hash_algorithm, bytes)) + Ok(SdpAttributeFingerprint { + fingerprint: self + .fingerprint + .split(':') + .collect::>() + .iter() + .map(|hex| u8::from_str_radix(hex, 16)) + .collect::, _>>() + .unwrap(), + hash_algorithm: SdpAttributeFingerprintHashType::try_from_name(self.hash.as_str())?, + }) + } + + pub fn set_setup(&mut self, sdp: &SdpAttributeSetup) { + self.setup = JingleTranportFingerprintSetup::from_sdp(sdp); + self.is_set = true; + } + + pub fn get_setup(&self) -> SdpAttributeSetup { + self.setup.to_sdp() + } + + pub fn is_set(&self) -> bool { + self.is_set + } +} + +// *** xep-0320 +#[derive(Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum JingleTranportFingerprintSetup { + Active, + Passive, + #[default] + Actpass, + //Role, // not part of XEP 0320 XML schemas but used inside the XEP + HoldConn, +} + +impl JingleTranportFingerprintSetup { + pub fn from_sdp(sdp: &SdpAttributeSetup) -> Self { + match sdp { + SdpAttributeSetup::Active => Self::Active, + SdpAttributeSetup::Actpass => Self::Actpass, + SdpAttributeSetup::Holdconn => Self::HoldConn, + SdpAttributeSetup::Passive => Self::Passive, + } + } + + pub fn to_sdp(&self) -> SdpAttributeSetup { + match self { + Self::Active => SdpAttributeSetup::Active, + Self::Actpass => SdpAttributeSetup::Actpass, + Self::HoldConn => SdpAttributeSetup::Holdconn, + Self::Passive => SdpAttributeSetup::Passive, + } + } +} diff --git a/rust/sdp-to-jingle/src/xep_0338.rs b/rust/sdp-to-jingle/src/xep_0338.rs new file mode 100644 index 0000000..a6c6ce4 --- /dev/null +++ b/rust/sdp-to-jingle/src/xep_0338.rs @@ -0,0 +1,94 @@ +use serde_derive::{Deserialize, Serialize}; +use webrtc_sdp::attribute_type::{SdpAttributeGroup, SdpAttributeGroupSemantic}; + +// *** xep-0338 +#[derive(Serialize, Deserialize)] +pub struct ContentGroup { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@semantics")] + semantics: GroupSemantics, + content: Vec, +} + +impl ContentGroup { + pub fn new_from_sdp(group: &SdpAttributeGroup) -> Self { + let mut content = Vec::new(); + for tag in &group.tags { + content.push(GroupContent::new(tag.to_string())); + } + Self { + xmlns: "urn:xmpp:jingle:apps:grouping:0".to_string(), + semantics: GroupSemantics::new_from_sdp(&group.semantics), + content, + } + } + + pub fn to_sdp(&self) -> SdpAttributeGroup { + let mut tags: Vec = Vec::new(); + for group_content in &self.content { + tags.push(group_content.get_tag()) + } + SdpAttributeGroup { + semantics: self.semantics.to_sdp(), + tags, + } + } +} + +// *** xep-0338 +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum GroupSemantics { + Ls, + Fid, + Srf, + Anat, + Fec, + Ddp, + Bundle, +} + +impl GroupSemantics { + pub fn new_from_sdp(semantics: &SdpAttributeGroupSemantic) -> Self { + match semantics { + SdpAttributeGroupSemantic::LipSynchronization => Self::Ls, + SdpAttributeGroupSemantic::FlowIdentification => Self::Fid, + SdpAttributeGroupSemantic::SingleReservationFlow => Self::Srf, + SdpAttributeGroupSemantic::AlternateNetworkAddressType => Self::Anat, + SdpAttributeGroupSemantic::ForwardErrorCorrection => Self::Fec, + SdpAttributeGroupSemantic::DecodingDependency => Self::Ddp, + SdpAttributeGroupSemantic::Bundle => Self::Bundle, + } + } + + pub fn to_sdp(&self) -> SdpAttributeGroupSemantic { + match self { + Self::Ls => SdpAttributeGroupSemantic::LipSynchronization, + Self::Fid => SdpAttributeGroupSemantic::FlowIdentification, + Self::Srf => SdpAttributeGroupSemantic::SingleReservationFlow, + Self::Anat => SdpAttributeGroupSemantic::AlternateNetworkAddressType, + Self::Fec => SdpAttributeGroupSemantic::ForwardErrorCorrection, + Self::Ddp => SdpAttributeGroupSemantic::DecodingDependency, + Self::Bundle => SdpAttributeGroupSemantic::Bundle, + } + } +} + +// *** xep-0338 +#[derive(Serialize, Deserialize, Default)] +#[serde(rename = "content")] +pub struct GroupContent { + #[serde(rename = "@name")] + name: String, +} + +impl GroupContent { + pub fn new(tag: String) -> Self { + Self { name: tag } + } + + pub fn get_tag(&self) -> String { + self.name.to_string() + } +} diff --git a/rust/sdp-to-jingle/src/xep_0339.rs b/rust/sdp-to-jingle/src/xep_0339.rs new file mode 100644 index 0000000..e33a43e --- /dev/null +++ b/rust/sdp-to-jingle/src/xep_0339.rs @@ -0,0 +1,147 @@ +use serde_derive::{Deserialize, Serialize}; +use webrtc_sdp::attribute_type::{SdpAttributeSsrc, SdpSsrcGroupSemantic}; + +use crate::jingle::{GenericParameter, GenericParameterEnum}; + +// *** xep-0339 +#[derive(Serialize, Deserialize, Clone)] +pub struct JingleSsrc { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@ssrc")] + id: u32, + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty", default)] + parameter: Vec, +} + +impl JingleSsrc { + pub fn new(id: u32) -> Self { + Self { + xmlns: "urn:xmpp:jingle:apps:rtp:ssma:0".to_string(), + id, + parameter: Vec::new(), + } + } + + pub fn add_parameter(&mut self, name: &str, value: Option) { + self.parameter + .push(GenericParameterEnum::Parameter(GenericParameter::new( + name.to_string(), + value, + ))); + } + + pub fn to_sdp(&self) -> Vec { + let mut retval: Vec = Vec::new(); + for entry in &self.parameter { + retval.push(SdpAttributeSsrc { + id: self.id, + attribute: Some(match entry { + GenericParameterEnum::Parameter(p) => p.name().to_string(), + GenericParameterEnum::Invalid => continue, + }), + value: match entry { + GenericParameterEnum::Parameter(p) => p, + GenericParameterEnum::Invalid => continue, + } + .value() + .map(|value| value.to_string()), + }); + } + retval + } +} + +// *** xep-0339 +#[derive(Serialize, Deserialize)] +pub struct JingleSsrcGroup { + #[serde(rename = "@xmlns", default)] + xmlns: String, + #[serde(rename = "@semantics")] + semantics: SsrcGroupSemantics, + #[serde(rename = "$value", skip_serializing_if = "Vec::is_empty")] + sources: Vec, +} + +impl JingleSsrcGroup { + pub fn new_from_sdp(semantics: &SdpSsrcGroupSemantic, sources: &Vec) -> Self { + let semantics = match semantics { + SdpSsrcGroupSemantic::Duplication => SsrcGroupSemantics::Dup, + SdpSsrcGroupSemantic::FlowIdentification => SsrcGroupSemantics::Fid, + SdpSsrcGroupSemantic::ForwardErrorCorrection => SsrcGroupSemantics::Fec, + SdpSsrcGroupSemantic::ForwardErrorCorrectionFr => SsrcGroupSemantics::FecFr, + SdpSsrcGroupSemantic::Sim => SsrcGroupSemantics::Sim, + }; + let mut sources_vec: Vec = Vec::new(); + for ssrc in sources { + sources_vec.push(JingleSsrcGroupSourceEnum::Source( + JingleSsrcGroupSource::new_from_sdp(ssrc), + )); + } + Self { + xmlns: "urn:xmpp:jingle:apps:rtp:ssma:0".to_string(), + semantics, + sources: sources_vec, + } + } + + pub fn to_sdp(&self) -> (SdpSsrcGroupSemantic, Vec) { + let semantics = match &self.semantics { + SsrcGroupSemantics::Dup => SdpSsrcGroupSemantic::Duplication, + SsrcGroupSemantics::Fid => SdpSsrcGroupSemantic::FlowIdentification, + SsrcGroupSemantics::Fec => SdpSsrcGroupSemantic::ForwardErrorCorrection, + SsrcGroupSemantics::FecFr => SdpSsrcGroupSemantic::ForwardErrorCorrectionFr, + SsrcGroupSemantics::Sim => SdpSsrcGroupSemantic::Sim, + }; + let mut sources_vec: Vec = Vec::new(); + for ssrc in &self.sources { + sources_vec.push(match ssrc { + JingleSsrcGroupSourceEnum::Source(src) => src.to_sdp(), + JingleSsrcGroupSourceEnum::Invalid => continue, + }); + } + (semantics, sources_vec) + } +} + +// *** xep-0339 +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum SsrcGroupSemantics { + Dup, + Fid, + Fec, + #[serde(rename = "FEC-FR")] + FecFr, + Sim, //not defined in the IANA registry?? see https://www.iana.org/assignments/sdp-parameters/sdp-parameters.xhtml#sdp-parameters-17 +} + +// *** xep-0339 +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum JingleSsrcGroupSourceEnum { + Source(JingleSsrcGroupSource), + #[serde(other)] + Invalid, +} + +// *** xep-0339 +#[derive(Serialize, Deserialize, Clone)] +pub struct JingleSsrcGroupSource { + #[serde(rename = "@ssrc")] + id: u32, +} + +impl JingleSsrcGroupSource { + pub fn new_from_sdp(ssrc: &SdpAttributeSsrc) -> Self { + Self { id: ssrc.id } + } + + pub fn to_sdp(&self) -> SdpAttributeSsrc { + SdpAttributeSsrc { + id: self.id, + attribute: None, + value: None, + } + } +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..b89ca65 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +function exportMacOS { + local EXPORT_OPTIONS_CATALYST="$1" + local BUILD_TYPE="$2" + + xcodebuild -exportArchive \ + -archivePath "build/macos_$APP_NAME.xcarchive" \ + -exportPath "build/app" \ + -exportOptionsPlist "$EXPORT_OPTIONS_CATALYST" \ + -allowProvisioningUpdates \ + -configuration $BUILD_TYPE + + echo "build dir:" + ls -l "build" +} + +# Abort on Error +set -e + +cd Monal + +security unlock-keychain -p $(cat /Users/ci/keychain.txt) login.keychain +security set-keychain-settings -t 3600 -l ~/Library/Keychains/login.keychain + +echo "" +echo "*******************************************" +echo "* Update localizations submodules *" +echo "*******************************************" +git submodule update -f --init --remote + +echo "" +echo "*******************************************" +echo "* Building rust packages & bridge *" +echo "*******************************************" + +bash ../rust/build-rust.sh + +echo "" +echo "***************************************" +echo "* Installing macOS & iOS Pods *" +echo "***************************************" +pod install --repo-update + +if [ "$BUILD_SCHEME" != "Quicksy" ]; then + echo "" + echo "***************************" + echo "* Archiving macOS *" + echo "***************************" + xcrun xcodebuild \ + -workspace "Monal.xcworkspace" \ + -scheme "$BUILD_SCHEME" \ + -sdk macosx \ + -configuration $BUILD_TYPE \ + -destination 'generic/platform=macOS,variant=Mac Catalyst,name=Any Mac' \ + -archivePath "build/macos_$APP_NAME.xcarchive" \ + -allowProvisioningUpdates \ + archive \ + BUILD_LIBRARIES_FOR_DISTRIBUTION=YES \ + SUPPORTS_MACCATALYST=YES + + echo "" + echo "****************************" + echo "* Exporting macOS *" + echo "****************************" + # see: https://gist.github.com/cocoaNib/502900f24846eb17bb29 + # and: https://forums.developer.apple.com/thread/100065 + # and: for developer-id distribution (distribution *outside* of appstore) an developer-id certificate must be used for building + if [ ! -z ${EXPORT_OPTIONS_CATALYST_APPSTORE} ]; then + echo "***************************************" + echo "* Exporting AppStore macOS *" + echo "***************************************" + exportMacOS "$EXPORT_OPTIONS_CATALYST_APPSTORE" "$BUILD_TYPE" + fi + + if [ ! -z ${EXPORT_OPTIONS_CATALYST_APP_EXPORT} ]; then + echo "***********************************" + echo "* Exporting app macOS *" + echo "***********************************" + exportMacOS "$EXPORT_OPTIONS_CATALYST_APP_EXPORT" "$BUILD_TYPE" + + echo "" + echo "**************************" + echo "* Packing macOS zip *" + echo "**************************" + cd build/app + mkdir tar_release + mv "$APP_NAME.app" "tar_release/$APP_DIR" + cd tar_release + /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP_DIR" "../$APP_NAME".zip + cd ../../.. + ls -l build/app + fi +fi + +echo "" +echo "*************************" +echo "* Archiving iOS *" +echo "*************************" +xcrun xcodebuild \ + -workspace "Monal.xcworkspace" \ + -scheme "$BUILD_SCHEME" \ + -sdk iphoneos \ + -configuration $BUILD_TYPE \ + -archivePath "build/ios_$APP_NAME.xcarchive" \ + -allowProvisioningUpdates \ + archive + +echo "" +echo "*************************" +echo "* Exporting iOS *" +echo "*************************" +# see: https://gist.github.com/cocoaNib/502900f24846eb17bb29 +# and: https://forums.developer.apple.com/thread/100065 +xcodebuild \ + -exportArchive \ + -archivePath "build/ios_$APP_NAME.xcarchive" \ + -exportPath "build/ipa" \ + -exportOptionsPlist $EXPORT_OPTIONS_IOS \ + -configuration $BUILD_TYPE \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration + +echo "build dir:" +find build diff --git a/scripts/exportOptions/Alpha_Catalyst_ExportOptions.plist b/scripts/exportOptions/Alpha_Catalyst_ExportOptions.plist new file mode 100644 index 0000000..8bdcfb4 --- /dev/null +++ b/scripts/exportOptions/Alpha_Catalyst_ExportOptions.plist @@ -0,0 +1,16 @@ + + + + + method + developer-id + compileBitcode + + uploadBitcode + + signingStyle + automatic + teamID + S8D843U34Y + + \ No newline at end of file diff --git a/scripts/exportOptions/Alpha_iOS_ExportOptions.plist b/scripts/exportOptions/Alpha_iOS_ExportOptions.plist new file mode 100644 index 0000000..14f670e --- /dev/null +++ b/scripts/exportOptions/Alpha_iOS_ExportOptions.plist @@ -0,0 +1,16 @@ + + + + + method + ad-hoc + compileBitcode + + uploadBitcode + + signingStyle + automatic + teamID + S8D843U34Y + + \ No newline at end of file diff --git a/scripts/exportOptions/Beta_Catalyst_ExportOptions.plist b/scripts/exportOptions/Beta_Catalyst_ExportOptions.plist new file mode 100644 index 0000000..326e3ad --- /dev/null +++ b/scripts/exportOptions/Beta_Catalyst_ExportOptions.plist @@ -0,0 +1,18 @@ + + + + + method + developer-id + destination + export + compileBitcode + + uploadBitcode + + signingStyle + automatic + teamID + S8D843U34Y + + diff --git a/scripts/exportOptions/Beta_iOS_ExportOptions.plist b/scripts/exportOptions/Beta_iOS_ExportOptions.plist new file mode 100644 index 0000000..a2dab0a --- /dev/null +++ b/scripts/exportOptions/Beta_iOS_ExportOptions.plist @@ -0,0 +1,16 @@ + + + + + method + app-store + compileBitcode + + uploadBitcode + + signingStyle + automatic + teamID + S8D843U34Y + + \ No newline at end of file diff --git a/scripts/exportOptions/Quicksy_Stable_iOS_ExportOptions.plist b/scripts/exportOptions/Quicksy_Stable_iOS_ExportOptions.plist new file mode 100644 index 0000000..a2dab0a --- /dev/null +++ b/scripts/exportOptions/Quicksy_Stable_iOS_ExportOptions.plist @@ -0,0 +1,16 @@ + + + + + method + app-store + compileBitcode + + uploadBitcode + + signingStyle + automatic + teamID + S8D843U34Y + + \ No newline at end of file diff --git a/scripts/exportOptions/Stable_Catalyst_ExportOptions.plist b/scripts/exportOptions/Stable_Catalyst_ExportOptions.plist new file mode 100644 index 0000000..d1ddf96 --- /dev/null +++ b/scripts/exportOptions/Stable_Catalyst_ExportOptions.plist @@ -0,0 +1,18 @@ + + + + + method + app-store + destination + export + compileBitcode + + uploadBitcode + + signingStyle + automatic + teamID + S8D843U34Y + + diff --git a/scripts/exportOptions/Stable_iOS_ExportOptions.plist b/scripts/exportOptions/Stable_iOS_ExportOptions.plist new file mode 100644 index 0000000..a2dab0a --- /dev/null +++ b/scripts/exportOptions/Stable_iOS_ExportOptions.plist @@ -0,0 +1,16 @@ + + + + + method + app-store + compileBitcode + + uploadBitcode + + signingStyle + automatic + teamID + S8D843U34Y + + \ No newline at end of file diff --git a/scripts/image_resize.sh b/scripts/image_resize.sh new file mode 100755 index 0000000..9e24773 --- /dev/null +++ b/scripts/image_resize.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +if [[ "$1" == "" ]]; then + echo "Usage: $(basename "$0") [type, for example: 'Alpha']" + exit 1 +fi + +type="$2" + +for d in ./Images.xcassets/${type}AppIcon.appiconset ./Images.xcassets/${type}AppLogo.imageset; do + for png in $d/*.png; do + size="$(identify -format "%wx%h" "$png")" + echo "$png ($size)" + #mv "$png" "${png%.png}.old" + convert "$1" -alpha off -filter triangle -resize "$size" "$png" + done +done; diff --git a/scripts/itu_pdf_to_objc.py b/scripts/itu_pdf_to_objc.py new file mode 100755 index 0000000..b0da6c8 --- /dev/null +++ b/scripts/itu_pdf_to_objc.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +import requests +import io +from pypdf import PdfReader +import re +import logging + +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)-7s] %(name)s {%(threadName)s} %(filename)s:%(lineno)d: %(message)s") +logger = logging.getLogger(__name__) + +class Quicksy_Country: + def __init__(self, alpha2mapping, name, alpha2, code, pattern): + self.alpha2mapping = alpha2mapping + self.name = name + self.alpha2 = alpha2 + self.code = code + self.pattern = pattern + + def __repr__(self): + # map ITU country names to wikidata names + itu2wikidata = { + "Ireland": "Republic of Ireland", + "China": "People's Republic of China", + "Taiwan, China": "Taiwan", + "Hong Kong, China": "Hong Kong", + "Gambia": "The Gambia", + "Falkland Islands (Malvinas)": "Falkland Islands", + "Dominican Rep.": "Dominican Republic", + "Dem. Rep. of the Congo": "Democratic Republic of the Congo", + "Congo": "Republic of the Congo", + "Czech Rep.": "Czech Republic", + "Dem. People's Rep. of Korea": "North Korea", + "Central African Rep.": "Central African Republic", + "Bolivia (Plurinational State of)": "Bolivia", + "Bahamas": "The Bahamas", + "Korea (Rep. of)": "South Korea", + "Iran (Islamic Republic of)": "Iran", + "Lao P.D.R.": "Laos", + "Moldova (Republic of)": "Moldova", + "Micronesia": "Federated States of Micronesia", + "Netherlands": "Kingdom of the Netherlands", + "Russian Federation": "Russia", + "Syrian Arab Republic": "Syria", + "The Former Yugoslav Republic of Macedonia": "North Macedonia", + "United States": "United States of America", + "Vatican": "Vatican City", + "Venezuela (Bolivarian Republic of)": "Venezuela", + "Viet Nam": "Vietnam", + "Swaziland": "Eswatini", + "Sint Maarten (Dutch part)": "Sint Maarten", + "Brunei Darussalam": "Brunei", + "Bonaire, Sint Eustatius and Saba": "Caribbean Netherlands", + "Côte d'Ivoire": "Ivory Coast", + "Sao Tome and Principe": "São Tomé and Príncipe", + "Timor-Leste": "East Timor", + "Northern Marianas": "Northern Mariana Islands", + } + country = self.name + if country in itu2wikidata: + country = itu2wikidata[country] + + # map ITU country names to wikidata names and return swift code with alpha-2 country code instead of localizable name + if country in alpha2mapping: + return f"[[Quicksy_Country alloc] initWithName:nil alpha2:@\"{alpha2mapping[country]}\" code:@\"{self.code}\" pattern:@\"{self.pattern}\"]," + # return swift code with localizable name for every country we don't know the alpha-2 code for + return f"[[Quicksy_Country alloc] initWithName:NSLocalizedString(@\"{self.name}\", @\"quicksy country\") alpha2:nil code:@\"{self.code}\" pattern:@\"{self.pattern}\"]," + +def parse_pdf(pdf_data, alpha2mapping): + logger.info("Parsing PDF...") + country_regex = re.compile(r'^(?P[^0-9]+)[ ]{32}(?P[0-9]+)[ ]{32}(?P.+)[ ]{32}(?P.+)[ ]{32}(?P.+ digits)[ ]{32}(?P.*)$') + country_end_regex = re.compile(r'^(?P.*)([ ]{32}(?P.+))?$') + countries = {} + pdf = PdfReader(io.BytesIO(pdf_data)) + pagenum = 0 + last_entry = None + for page in pdf.pages: + pagenum += 1 + countries[pagenum] = [] + logger.info(f"Starting to analyze page {pagenum}...") + text = page.extract_text(extraction_mode="layout", layout_mode_space_vertically=False) + if text and "Country/geographical area" in text and "Country" in text and "International" in text and "National" in text and "National (Significant)" in text and "UTC/DST" in text and "Note" in text: + for line in text.split("\n"): + #this is faster than having a "{128,} in the compiled country_regex + match = country_regex.match(re.sub("[ ]{128,}", " "*32, line)) + if match == None: + # check if this is just a linebreak in the country name and append the value to the previous country + if re.sub("[ ]{128,}", " "*32, line) == line.strip() and last_entry != None and "Annex to ITU" not in line: + logger.debug(f"Adding to last country name: {line=}") + countries[pagenum][last_entry].name += f" {line.strip()}" + else: + last_entry = None # don't append line continuations of non-real countries to a real country + else: + match = match.groupdict() | {"dst": None, "notes": None} + if match["end"] and match["end"].strip() != "": + end_splitting = match["end"].split(" "*32) + if len(end_splitting) >= 1: + match["dst"] = end_splitting[0] + if len(end_splitting) >= 2: + match["notes"] = end_splitting[1] + match = {key: (value.strip() if value != None else None) for key, value in match.items()} + # logger.debug("****************") + # logger.debug(f"{match['country'] = }") + # logger.debug(f"{match['code'] = }") + # logger.debug(f"{match['international_prefix'] = }") + # logger.debug(f"{match['national_prefix'] = }") + # logger.debug(f"{match['format'] = }") + # logger.debug(f"{match['dst'] = }") + # logger.debug(f"{match['notes'] = }") + + if match["dst"] == None: # all real countries have a dst entry + last_entry = None # don't append line continuations of non-real countries to a real country + else: + country_code = f"+{match['code']}" + pattern = subpattern_matchers(match['format'], True) + superpattern = matcher(pattern, r"(\([0-9/]+\))[ ]*\+[ ]*(.+)[ ]+digits", match['format'], lambda result: result) + if pattern == None and superpattern != None: + #logger.debug(f"Trying superpattern: '{match['format']}' --> '{superpattern.group(1)}' ## '{superpattern.group(2)}'") + subpattern = subpattern_matchers(superpattern.group(2), False) + if subpattern != None: + pattern = re.sub("/", "|", superpattern.group(1)) + subpattern + if pattern == None: + logger.warning(f"Unknown format description for {match['country']} ({country_code}): '{match['format']}'") + pattern = "[0-9]+" + country = Quicksy_Country(alpha2mapping, match['country'], None, country_code, f"^{pattern}$") + countries[pagenum].append(country) + last_entry = len(countries[pagenum]) - 1 + logger.info(f"Page {pagenum}: Found {len(countries[pagenum])} countries so far...") + + logger.info(f"Parsing finished: Extracted {sum([len(cs) for cs in countries.values()])} countries...") + return [c for cs in countries.values() for c in cs] + +def matcher(previous_result, regex, text, closure): + if previous_result != None: + return previous_result + matches = re.match(regex, text) + if matches == None: + return None + else: + return closure(matches) + +def subpattern_matchers(text, should_end_with_unit): + if should_end_with_unit: + if text[-6:] != "digits": + logger.error(f"should_end_with_unit set but not ending in 'digits': {text[-6:] = }") + return None + text = text[:-6] + + def subdef(result): + retval = f"[0-9]{{" + grp1 = result.group(1) if result.group(1) != "up" else "1" + retval += f"{grp1}" + if result.group(3) != None: + retval += f",{result.group(3)}" + retval += f"}}" + return retval + pattern = [] + parts = [x.strip() for x in text.split(",")] + for part in parts: + result = matcher(None, r"(up|[0-9]+)([ ]*to[ ]*([0-9]+)[ ]*)?", part, subdef) + #logger.debug(f"{part=} --> {result=}") + if result != None: + pattern.append(result) + if len(pattern) == 0: + return None + return "(" + "|".join(pattern) + ")" + +def get_sparql_results(query): + import sys + from SPARQLWrapper import SPARQLWrapper, JSON + user_agent = "monal-im itu pdf parser/%s.%s" % (sys.version_info[0], sys.version_info[1]) + sparql = SPARQLWrapper("https://query.wikidata.org/sparql", agent=user_agent) + sparql.setQuery(query) + sparql.setReturnFormat(JSON) + return sparql.query().convert() + + +logger.info("Downloading Wikidata country names to ISO 3166-1 alpha-2 codes mapping...") +results = get_sparql_results("""SELECT ?country ?countryLabel ?code WHERE { + ?country wdt:P297 ?code . + SERVICE wikibase:label { bd:serviceParam wikibase:language "en" } +}""") +alpha2mapping = {result["countryLabel"]["value"]: result["code"]["value"] for result in results["results"]["bindings"]} + +logger.info("Downloading PDF...") +response = requests.get("https://www.itu.int/dms_pub/itu-t/opb/sp/T-SP-E.164C-2011-PDF-E.pdf") +countries = parse_pdf(response.content, alpha2mapping) + +# output complete swift code +print("""// 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 = @[""") +for country in countries: + print(f" {country}") +print(""" ]; + }); + } +} + +@end""") diff --git a/scripts/mail2webhook.py b/scripts/mail2webhook.py new file mode 100755 index 0000000..8862389 --- /dev/null +++ b/scripts/mail2webhook.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +import sys +import argparse +import email +import email.parser +import re +import requests + +# see https://stackoverflow.com/a/60978847/3528174 +def to_camel_case(text): + s = text.replace("-", " ").replace("_", " ") + s = s.split() + if len(text) == 0: + return text + return s[0].lower() + ''.join(i.capitalize() for i in s[1:]) + +# parse commandline +parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, description="Simple python script to trigger a github ") +parser.add_argument("--token", metavar='TOKEN', required=True, help="Github token to use to authenticate the workflow trigger workflow") +parser.add_argument("--repo", metavar='REPO', required=True, help="Github user/organisation and repository name to trigger the workflow in (Example: 'monal-im/Monal')") +parser.add_argument("--type", metavar='TYPE', required=True, help="Event type to trigger the github workflow with") +parser.add_argument("--filter", metavar='FILTER', default=[], action='append', required=False, help="'key=value-regex' pairs that should be used to filter the app properties given in the mail body") +args = parser.parse_args() + + +parser = email.parser.BytesParser() +message = parser.parse(sys.stdin.buffer) + +subject = re.sub(r'\s+', ' ', message["subject"]).strip() +date = message["date"] + +# python > 3.9 variant +#body = message.get_body(preferencelist=("plain",)) + +# python <= 3.9 variant +# see https://stackoverflow.com/a/32840516/3528174 +body = "" +if message.is_multipart(): + for part in message.walk(): + ctype = part.get_content_type() + cdispo = str(part.get('Content-Disposition')) + if ctype == 'text/plain' and 'attachment' not in cdispo: + body = part.get_payload(decode=True) # decode + break +else: + body = message.get_payload(decode=True) + +# transform body into an array of stripped strings +body = [s.strip() for s in str(body, 'UTF-8').split("\n")] + +# parse app properties +properties = {to_camel_case(k.strip()): v.strip() for k, v in [line.split(": ", 1) for line in body if len(line.split(": ", 1)) > 1]} + +# sanity checks and state extraction +match = re.match(r"^The status of your \((?P.+)\) app, (?P.+), is now \"(?P.+)\"$", subject) +if match == None: + print(f"Mail subject does not contain proper state: '{subject}'", file=sys.stderr) + sys.exit(0) +state = {"_"+to_camel_case(k.strip()): v.strip() for k, v in match.groupdict().items()} +state["_state"] = to_camel_case(state["_state"].strip()) +if state["_appName"] != properties["appName"]: + print(f"Mail subject states different app name than properties in mail body: stateAppName='{state['_appName']}', appName='{properties['appName']}'", file=sys.stderr) + sys.exit(0) + +# merge body properties and extracted state +properties = state | {"_datetime": date} | properties +#print(properties) + +# filter everything using the given commandline arguments +for entry in args.filter: + k, v = entry.split("=", 1) + if k not in properties: + print(f"Unknown filter key: '{k}'", file=sys.stderr) + sys.exit(0) + if re.search(v, properties[k]) == None: + print(f"Wrong {k}: '{properties[k]}'", file=sys.stderr) + sys.exit(0) + +# trigger workflow +with requests.post(f"https://api.github.com/repos/{args.repo}/dispatches", json={ + "event_type": args.type, + "client_payload": properties, +}, headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Authorization": f"Bearer {args.token}" +}) as r: + r.raise_for_status() + +sys.exit(0) diff --git a/scripts/power.py b/scripts/power.py new file mode 100755 index 0000000..734c29e --- /dev/null +++ b/scripts/power.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +import zipfile +import json +import re +from datetime import datetime +import argparse +import statistics +import logging, logging.config +import sys + +def calculate_power_hour_ratio(start, end): + time_format = "%d.%m.%y, %H:%M" + start_time = datetime.strptime(start['time'], time_format) + end_time = datetime.strptime(end['time'], time_format) + time_diff_hours = (end_time - start_time).total_seconds() / 3600 + logger.debug(f"{time_diff_hours=}, {start_time=}, {end_time=}, {start['power']=}, {end['power']=}, {start['charging']=}, {end['charging']=}") + if time_diff_hours == 0: + return None + return (end['power'] - start['power']) / time_diff_hours + +def process_zip(zip_file_path): + logger.info(f"Processing zip file at '{zip_file_path}'...") + data_points = [] + with zipfile.ZipFile(zip_file_path, 'r') as zip_file: + sorted_files = [] + for file_name in zip_file.namelist(): + if re.match(r'^[^/]+-(\d+)\.json$', file_name): + sorted_files.append((re.search(r'^[^/]+-(\d+)\.json$', file_name).group(1), file_name)) + elif re.match(r'^[^/]+\.json$', file_name): + sorted_files.append(("1", file_name)) + for _, file_name in sorted_files: + logger.debug(f"Parsing file: {file_name}") + with zip_file.open(file_name) as json_file: + data = json.load(json_file) + data_points.append(data) + logger.info(f"Zip file successfully processed, {len(data_points)} data-points extracted...") + data_points.sort(key=lambda x: datetime.strptime(x["time"], "%d.%m.%y, %H:%M")) + return data_points + +def calculate_power_ratios(data_points): + logger.info(f"Calculating power ratios...") + discharging = [] + charging = [] + ignored_discharging_periods = 0 + ignored_charging_periods = 0 + for i in range(1, len(data_points)): + start = data_points[i-1] + end = data_points[i] + power_hour_ratio = calculate_power_hour_ratio(start, end) + if start['charging'] == False and end['charging'] == True: + if power_hour_ratio is None or power_hour_ratio > 0: + ignored_discharging_periods += 1 + logger.debug(f"Ignoring discharging period: {start} - {end} --> {power_hour_ratio}...") + continue + discharging.append(power_hour_ratio) + elif start['charging'] == True and end['charging'] == False: + if power_hour_ratio is None or power_hour_ratio < 0: + ignored_charging_periods += 1 + logger.debug(f"Ignoring charging period: {start} - {end}...") + continue + charging.append(power_hour_ratio) + # elif start['charging'] == False and end['charging'] == False: + # if power_hour_ratio is None or power_hour_ratio > 0: + # ignored_discharging_periods += 1 + # logger.debug(f"Ignoring discharging period: {start} - {end} --> {power_hour_ratio}...") + # continue + # discharging.append(power_hour_ratio) + elif start['charging'] == True and end['charging'] == True: + if power_hour_ratio is None or power_hour_ratio < 0: + ignored_charging_periods += 1 + logger.debug(f"Ignoring charging period: {start} - {end} --> {power_hour_ratio}...") + continue + charging.append(power_hour_ratio) + else: + logger.error(f"Unexpected ({'unusable' if power_hour_ratio is None else 'usable'}) datapoints: {start}, {end}") + continue + discharging_median = statistics.median(discharging) if len(discharging)>0 else 0 + discharging_mean = statistics.mean(discharging) if len(discharging)>0 else 0 + charging_median = statistics.median(charging) if len(charging)>0 else 0 + charging_mean = statistics.mean(charging) if len(charging)>0 else 0 + logger.info(f"Power ratios calculated: {len(discharging)+ignored_discharging_periods} discharging periods ({len(discharging)} usable), {len(charging)+ignored_charging_periods} charging periods ({len(charging)} usable)...") + return discharging, discharging_median, discharging_mean, charging, charging_median, charging_mean + +parser = argparse.ArgumentParser(description="Process a zip file of JSON files containing power, time, and charging data.") +parser.add_argument('--file', metavar='file.zip', required=True, help="Path to the zip file") +parser.add_argument("--log", metavar='LOGLEVEL', help="Loglevel to log", default="INFO") +args = parser.parse_args() + +logging.config.dictConfig({ + "version": 1, + "disable_existing_loggers": False, + + "formatters": { + "simple": { + "format": "%(asctime)s [%(levelname)-7s] %(name)s {%(threadName)s} %(filename)s:%(lineno)d: %(message)s", + "color": True + } + }, + + "handlers": { + "stderr": { + "class": "logging.StreamHandler", + "level": args.log, + "formatter": "simple" + }, + "ignore": { + "class": "logging.NullHandler", + "level": "DEBUG" + } + }, + + "loggers": { + "": { + "level": "DEBUG", + "handlers": ["stderr"] + } + } +}) +logger = logging.getLogger(__name__) + +data_points = process_zip(args.file) +discharging, discharging_median, discharging_mean, charging, charging_median, charging_mean = calculate_power_ratios(data_points) + +print(f"{args.file}: Discharging ratios (median: {discharging_median:.3f}, mean: {discharging_mean:.3f}):", discharging) +print(f"{args.file}: Charging ratios (median: {charging_median:.3f}, mean: {charging_mean:.3f}):", charging) diff --git a/scripts/power_consumption.shortcut b/scripts/power_consumption.shortcut new file mode 100644 index 0000000..a22b44f Binary files /dev/null and b/scripts/power_consumption.shortcut differ diff --git a/scripts/prepare-alpha-certs.sh b/scripts/prepare-alpha-certs.sh new file mode 100755 index 0000000..9a86c32 --- /dev/null +++ b/scripts/prepare-alpha-certs.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +KEY_PASSWORD="$(cat /Users/ci/encryption_secret.txt)" + +#decrypt all encrypted files +for file in ./scripts/*.enc; do + echo "Decrypting '$file' --> '${file%%.enc}'..." + openssl aes-256-cbc -k "$KEY_PASSWORD" -md sha256 -in "$file" -d -a -out "${file%%.enc}" +done + +cd scripts + +# Create a custom keychain +security create-keychain -p travis ios-build.keychain +# Make the custom keychain default, so xcodebuild will use it for signing +security default-keychain -s ios-build.keychain +# Unlock the keychain +security unlock-keychain -p travis ios-build.keychain +# Set keychain timeout to 1 hour for long builds +security set-keychain-settings -t 3600 -l ~/Library/Keychains/ios-build.keychain +# Add certificates to keychain and allow codesign to access them +echo "Importing 'apple.cer' into keychain..." +security import apple.cer -k ~/Library/Keychains/ios-build.keychain -T /usr/bin/codesign > /dev/null +for file in *.p12; do + cert="${file%%.p12}" + echo "Importing '$cert.p12' and '$cert.cer' into keychain..." + security import $cert.cer -k ~/Library/Keychains/ios-build.keychain -T /usr/bin/codesign > /dev/null + security import $cert.p12 -k ~/Library/Keychains/ios-build.keychain -P 1234 -T /usr/bin/codesign > /dev/null +done +# Set Key partition list +security set-key-partition-list -S apple-tool:,apple: -s -k travis ios-build.keychain +security list-keychains -s ios-build.keychain + +# Put the provisioning profile in place +mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles +cp *.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ +cp *.provisionprofile ~/Library/MobileDevice/Provisioning\ Profiles/ diff --git a/scripts/push_xmpp.org.sh b/scripts/push_xmpp.org.sh new file mode 100755 index 0000000..dc9fda2 --- /dev/null +++ b/scripts/push_xmpp.org.sh @@ -0,0 +1,68 @@ +#!/bin/sh + +# Abort on Error +set -e + +cd Monal + +echo "" +echo "**********************************************" +echo "* Reading buildNumber and creating timestamp *" +echo "**********************************************" +buildNumber=$(git tag --sort="v:refname" | grep -v "Quicksy_Build_iOS" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') +timestamp="$(date -u +%FT%T)" + +echo "" +echo "*********************************************" +echo "* Cloning and resetting xmpp.org repository *" +echo "*********************************************" + +if [[ -e "xmpp.org" ]]; then + rm -rf xmpp.org +fi +git clone git@xmpp.org.push.repo:monal-im/xmpp.org.git +cd xmpp.org +git config --local user.email "pushBot@monal-im.org" +git config --local user.name "Monal-IM-Push[BOT]" +git remote add upstream https://github.com/xsf/xmpp.org.git +git fetch upstream +git checkout -b monal-release-push +git reset --hard upstream/master + +echo "" +echo "******************************************" +echo "* Changing Monal timestamp for build $buildNumber *" +echo "******************************************" + +awk '/"name": "Monal IM",/{sub(/"last_renewed": "[0-9T:-]+",$/, "\"last_renewed\": \"'$timestamp'\",", last)} NR>1{print last} {last=$0} END {print last}' data/clients.json >data/clients.json.new +cat data/clients.json.new >data/clients.json +rm data/clients.json.new + +echo "" +echo "*********************************" +echo "* Creating commit for build $buildNumber *" +echo "*********************************" + +git add -u +git commit -m "New timestamp for Monal stable release with build number $buildNumber" +git push --set-upstream origin monal-release-push --force + +echo "" +echo "******************************************************************" +echo "* Amending last commit in master to trigger PR creating workflow *" +echo "******************************************************************" + +git checkout master +git commit -C HEAD --amend --no-edit +git push --force-with-lease + +echo "" +echo "***************" +echo "* Cleaning up *" +echo "***************" + +cd .. +rm -rf xmpp.org + + +exit 0 \ No newline at end of file diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..8e809ad --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +PyPDF @ git+https://github.com/py-pdf/pypdf@4.3.1 \ No newline at end of file diff --git a/scripts/saslprep_table_translator.py b/scripts/saslprep_table_translator.py new file mode 100755 index 0000000..aebffc3 --- /dev/null +++ b/scripts/saslprep_table_translator.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +import re + +# Function to generate the NSMakeRange output from the table of code points +def generate_ns_make_range(code_points): + for entry in code_points: + # If the entry is a range (tuple), generate the range NSMakeRange + if isinstance(entry, tuple): + start, end = entry + print(f'[unassignedCodePointsCharacterSet addCharactersInRange:NSMakeRange(0x{start:04X}, 0x{end-start+1:X})]; // U+{start:04X} to U+{end:04X}') + else: + # Otherwise, it's a single code point, so generate the single character NSMakeRange + print(f'[unassignedCodePointsCharacterSet addCharactersInRange:NSMakeRange(0x{entry:04X}, 1)]; // U+{entry:04X}') + +# Parse the input Unicode code points and ranges +def parse_input(input_str): + # Regular expression to match single code points or ranges in the form "start-end" + pattern = r'([0-9A-Fa-f]+(?:-[0-9A-Fa-f]+)?)' + + # Find all matches in the input string + matches = re.findall(pattern, input_str) + + # Convert matches to a list of integers or tuples for ranges + code_points = [] + + for match in matches: + if '-' in match: + # It's a range (start-end), split and convert to integers + start, end = match.split('-') + code_points.append((int(start, 16), int(end, 16))) + else: + # It's a single code point + code_points.append(int(match, 16)) + + return code_points + +input_str = """ + 0221 + 0234-024F + 02AE-02AF + 02EF-02FF + 0350-035F + 0370-0373 + 0376-0379 + 037B-037D + 037F-0383 + 038B + 038D + 03A2 + 03CF + 03F7-03FF + 0487 + 04CF + 04F6-04F7 + 04FA-04FF + 0510-0530 + 0557-0558 + 0560 + 0588 + 058B-0590 + 05A2 + 05BA + 05C5-05CF + 05EB-05EF + 05F5-060B + 060D-061A + 061C-061E + 0620 + 063B-063F + 0656-065F + 06EE-06EF + 06FF + 070E + 072D-072F + 074B-077F + 07B2-0900 + + + 0904 + 093A-093B + 094E-094F + 0955-0957 + 0971-0980 + 0984 + 098D-098E + 0991-0992 + 09A9 + 09B1 + 09B3-09B5 + 09BA-09BB + 09BD + 09C5-09C6 + 09C9-09CA + 09CE-09D6 + 09D8-09DB + 09DE + 09E4-09E5 + 09FB-0A01 + 0A03-0A04 + 0A0B-0A0E + 0A11-0A12 + 0A29 + 0A31 + 0A34 + 0A37 + 0A3A-0A3B + 0A3D + 0A43-0A46 + 0A49-0A4A + 0A4E-0A58 + 0A5D + 0A5F-0A65 + 0A75-0A80 + 0A84 + 0A8C + 0A8E + 0A92 + 0AA9 + 0AB1 + 0AB4 + 0ABA-0ABB + 0AC6 + 0ACA + 0ACE-0ACF + 0AD1-0ADF + 0AE1-0AE5 + + + 0AF0-0B00 + 0B04 + 0B0D-0B0E + 0B11-0B12 + 0B29 + 0B31 + 0B34-0B35 + 0B3A-0B3B + 0B44-0B46 + 0B49-0B4A + 0B4E-0B55 + 0B58-0B5B + 0B5E + 0B62-0B65 + 0B71-0B81 + 0B84 + 0B8B-0B8D + 0B91 + 0B96-0B98 + 0B9B + 0B9D + 0BA0-0BA2 + 0BA5-0BA7 + 0BAB-0BAD + 0BB6 + 0BBA-0BBD + 0BC3-0BC5 + 0BC9 + 0BCE-0BD6 + 0BD8-0BE6 + 0BF3-0C00 + 0C04 + 0C0D + 0C11 + 0C29 + 0C34 + 0C3A-0C3D + 0C45 + 0C49 + 0C4E-0C54 + 0C57-0C5F + 0C62-0C65 + 0C70-0C81 + 0C84 + 0C8D + 0C91 + 0CA9 + 0CB4 + + 0CBA-0CBD + 0CC5 + 0CC9 + 0CCE-0CD4 + 0CD7-0CDD + 0CDF + 0CE2-0CE5 + 0CF0-0D01 + 0D04 + 0D0D + 0D11 + 0D29 + 0D3A-0D3D + 0D44-0D45 + 0D49 + 0D4E-0D56 + 0D58-0D5F + 0D62-0D65 + 0D70-0D81 + 0D84 + 0D97-0D99 + 0DB2 + 0DBC + 0DBE-0DBF + 0DC7-0DC9 + 0DCB-0DCE + 0DD5 + 0DD7 + 0DE0-0DF1 + 0DF5-0E00 + 0E3B-0E3E + 0E5C-0E80 + 0E83 + 0E85-0E86 + 0E89 + 0E8B-0E8C + 0E8E-0E93 + 0E98 + 0EA0 + 0EA4 + 0EA6 + 0EA8-0EA9 + 0EAC + 0EBA + 0EBE-0EBF + 0EC5 + 0EC7 + 0ECE-0ECF + + 0EDA-0EDB + 0EDE-0EFF + 0F48 + 0F6B-0F70 + 0F8C-0F8F + 0F98 + 0FBD + 0FCD-0FCE + 0FD0-0FFF + 1022 + 1028 + 102B + 1033-1035 + 103A-103F + 105A-109F + 10C6-10CF + 10F9-10FA + 10FC-10FF + 115A-115E + 11A3-11A7 + 11FA-11FF + 1207 + 1247 + 1249 + 124E-124F + 1257 + 1259 + 125E-125F + 1287 + 1289 + 128E-128F + 12AF + 12B1 + 12B6-12B7 + 12BF + 12C1 + 12C6-12C7 + 12CF + 12D7 + 12EF + 130F + 1311 + 1316-1317 + 131F + 1347 + 135B-1360 + 137D-139F + 13F5-1400 + + 1677-167F + 169D-169F + 16F1-16FF + 170D + 1715-171F + 1737-173F + 1754-175F + 176D + 1771 + 1774-177F + 17DD-17DF + 17EA-17FF + 180F + 181A-181F + 1878-187F + 18AA-1DFF + 1E9C-1E9F + 1EFA-1EFF + 1F16-1F17 + 1F1E-1F1F + 1F46-1F47 + 1F4E-1F4F + 1F58 + 1F5A + 1F5C + 1F5E + 1F7E-1F7F + 1FB5 + 1FC5 + 1FD4-1FD5 + 1FDC + 1FF0-1FF1 + 1FF5 + 1FFF + 2053-2056 + 2058-205E + 2064-2069 + 2072-2073 + 208F-209F + 20B2-20CF + 20EB-20FF + 213B-213C + 214C-2152 + 2184-218F + 23CF-23FF + 2427-243F + 244B-245F + 24FF + + 2614-2615 + 2618 + 267E-267F + 268A-2700 + 2705 + 270A-270B + 2728 + 274C + 274E + 2753-2755 + 2757 + 275F-2760 + 2795-2797 + 27B0 + 27BF-27CF + 27EC-27EF + 2B00-2E7F + 2E9A + 2EF4-2EFF + 2FD6-2FEF + 2FFC-2FFF + 3040 + 3097-3098 + 3100-3104 + 312D-3130 + 318F + 31B8-31EF + 321D-321F + 3244-3250 + 327C-327E + 32CC-32CF + 32FF + 3377-337A + 33DE-33DF + 33FF + 4DB6-4DFF + 9FA6-9FFF + A48D-A48F + A4C7-ABFF + D7A4-D7FF + FA2E-FA2F + FA6B-FAFF + FB07-FB12 + FB18-FB1C + FB37 + FB3D + FB3F + FB42 + + FB45 + FBB2-FBD2 + FD40-FD4F + FD90-FD91 + FDC8-FDCF + FDFD-FDFF + FE10-FE1F + FE24-FE2F + FE47-FE48 + FE53 + FE67 + FE6C-FE6F + FE75 + FEFD-FEFE + FF00 + FFBF-FFC1 + FFC8-FFC9 + FFD0-FFD1 + FFD8-FFD9 + FFDD-FFDF + FFE7 + FFEF-FFF8 + 10000-102FF + 1031F + 10324-1032F + 1034B-103FF + 10426-10427 + 1044E-1CFFF + 1D0F6-1D0FF + 1D127-1D129 + 1D1DE-1D3FF + 1D455 + 1D49D + 1D4A0-1D4A1 + 1D4A3-1D4A4 + 1D4A7-1D4A8 + 1D4AD + 1D4BA + 1D4BC + 1D4C1 + 1D4C4 + 1D506 + 1D50B-1D50C + 1D515 + 1D51D + 1D53A + 1D53F + 1D545 + + 1D547-1D549 + 1D551 + 1D6A4-1D6A7 + 1D7CA-1D7CD + 1D800-1FFFD + 2A6D7-2F7FF + 2FA1E-2FFFD + 30000-3FFFD + 40000-4FFFD + 50000-5FFFD + 60000-6FFFD + 70000-7FFFD + 80000-8FFFD + 90000-9FFFD + A0000-AFFFD + B0000-BFFFD + C0000-CFFFD + D0000-DFFFD + E0000 + E0002-E001F + E0080-EFFFD +""" + +code_points = parse_input(input_str) +generate_ns_make_range(code_points) + diff --git a/scripts/set_version_number.sh b/scripts/set_version_number.sh new file mode 100755 index 0000000..f036bfd --- /dev/null +++ b/scripts/set_version_number.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# Abort on Error +set -e + +cd Monal + +echo "" +echo "***************************************************" +echo "* Setting buildNumber to $buildNumber and version to $buildVersion *" +echo "***************************************************" +sleep 1 + +set -x + +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "NotificationService/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "shareSheet-iOS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "$APP_NAME-Info.plist" + +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $buildVersion" "NotificationService/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $buildVersion" "shareSheet-iOS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $buildVersion" "$APP_NAME-Info.plist" diff --git a/scripts/simulate_network_delay.sh b/scripts/simulate_network_delay.sh new file mode 100755 index 0000000..d30521e --- /dev/null +++ b/scripts/simulate_network_delay.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +iiface="bond0" +oiface="virbr0" +ip="212.21.75.16" + +tc qdisc del dev $iiface root +tc qdisc add dev $iiface root handle 1: prio +tc qdisc add dev $iiface parent 1:3 handle 30: tbf rate 16kbit burst 1600 limit 3000 +tc qdisc add dev $iiface parent 30:1 handle 31: netem delay 2000ms 10ms distribution normal +tc filter add dev $iiface protocol ip parent 1:0 prio 3 u32 match ip dst $ip/32 flowid 1:3 + +tc qdisc del dev $oiface root +tc qdisc add dev $oiface root handle 1: prio +tc qdisc add dev $oiface parent 1:3 handle 30: tbf rate 16kbit burst 1600 limit 3000 +#tc qdisc add dev $oiface parent 30:1 handle 31: netem delay 3000ms 10ms distribution normal +tc filter add dev $oiface protocol ip parent 1:0 prio 3 u32 match ip src $ip/32 flowid 1:3 + diff --git a/scripts/updateAlphaHomebrew.sh b/scripts/updateAlphaHomebrew.sh new file mode 100755 index 0000000..91ef094 --- /dev/null +++ b/scripts/updateAlphaHomebrew.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# Abort on Error +set -e + +EPOCH=$(date +%s) +SHASUM=$(shasum -a 256 ./Monal/build/app/Monal.alpha.tar | awk '{print $1}') + +echo "" +echo "*********************************************" +echo "* Cloning and resetting homebrew-monal-alpha repository *" +echo "*********************************************" + +if [[ -e "homebrew-monal-alpha" ]]; then + rm -rf homebrew-monal-alpha +fi +git clone git@github.org.homebrew-monal-alpha.push.repo:monal-im/homebrew-monal-alpha.git +cd homebrew-monal-alpha +git config --local user.email "pushBot@monal-im.org" +git config --local user.name "Monal-IM-Push[BOT]" + +awk -v timestamp="$EPOCH" -v shasum="$SHASUM" 'sub(/#timestampAsVersion#/,timestamp)sub(/#macosHash#/,shasum)1' templates/Casks/monal-alpha.rb > Casks/monal-alpha.rb + +git add Casks/monal-alpha.rb +git commit -m "Publish new version" +git push + +echo "" +echo "***************" +echo "* Cleaning up *" +echo "***************" + +cd .. +rm -rf homebrew-monal-alpha + + +exit 0 + diff --git a/scripts/updateLocalization.sh b/scripts/updateLocalization.sh new file mode 100755 index 0000000..5b2cdf7 --- /dev/null +++ b/scripts/updateLocalization.sh @@ -0,0 +1,172 @@ +#/bin/bash + +set -e + +cd "$(dirname "$0")" +cd ../Monal + +if ! which bartycrouch > /dev/null; then + echo "ERROR: BartyCrouch not installed, download it from https://github.com/Flinesoft/BartyCrouch" + exit 1 +fi + +compile_swift="NO" +if [ "x$2" != "x" ]; then + compile_swift="$2" +fi + +function pullCurrentState { + #subshell to not leak from "cd $folder" + ( + cd "localization/external" + if [[ $1 == "BUILDSERVER" ]]; then + git remote set-url origin git@main.translation.repo:monal-im/Monal-localization-main.git + else + git remote set-url origin git@github.com:monal-im/Monal-localization-main.git + fi + echo "Git remote is now:" + git remote --verbose + git checkout main + git reset --hard origin/main + ) + #subshell to not leak from "cd $folder" + ( + cd "shareSheet-iOS/localization/external" + if [[ $1 == "BUILDSERVER" ]]; then + git remote set-url origin git@sharesheet.translation.repo:monal-im/Monal-localization-shareSheet.git + else + git remote set-url origin git@github.com:monal-im/Monal-localization-shareSheet.git + fi + echo "Git remote is now:" + git remote --verbose + git checkout main + git reset --hard origin/main + ) +} + +function runBartycrouch { + # https://github.com/Flinesoft/BartyCrouch#exclude-specific-views--nslocalizedstrings-from-localization + # update normally using bartycrouch and use it to sync our SwiftUI translations from base language to all other languages + bartycrouch update -x + # clean up all files + for folder in "localization/external" "shareSheet-iOS/localization/external"; do + for file in $folder/*.lproj/*.strings; do + # Remove empty lines + sed -i '' '/^$/d' $file + # Remove default comments that are not supported by weblate + sed -i '' '/^\/\* No comment provided by engineer\. \*\/$/d' $file + # Fix empty RHS + sed -E -i '' 's|^(.*) = "";$|\1 = \1;|' $file + done + done + # lint everything now + bartycrouch lint -x -w +} + +echo "" +echo "***************************************" +echo "* Initializing submodules *" +echo "***************************************" +git submodule deinit --all -f +git submodule update --init --recursive --remote +pullCurrentState "$@" + +if [ "$compile_swift" == "YES" ]; then + echo "" + echo "*******************************************" + echo "* Building rust packages & bridge *" + echo "*******************************************" + bash ../rust/build-rust.sh + + echo "" + echo "***************************************" + echo "* Installing macOS & iOS Pods *" + echo "***************************************" + pod install --repo-update +fi + +echo "" +echo "***************************************" +echo "* Removing unused strings *" +echo "***************************************" +# update strings to remove everything that's now unused (that includes swiftui strings we'll readd below) +cp .bartycrouch.toml .bartycrouch.toml.orig +sed 's/additive = true/additive = false/g' .bartycrouch.toml > .bartycrouch.toml.new +rm .bartycrouch.toml +mv .bartycrouch.toml.new .bartycrouch.toml +runBartycrouch +rm .bartycrouch.toml +mv .bartycrouch.toml.orig .bartycrouch.toml +# now restore original state for all languages but our base one (otherwise every swiftui translation will be deleted) +mv "localization/external/Base.lproj/Localizable.strings" "localization/external/Base.lproj/Localizable.strings.updated" +pullCurrentState "$@" +mv "localization/external/Base.lproj/Localizable.strings.updated" "localization/external/Base.lproj/Localizable.strings" + +echo "" +echo "***************************************" +echo "* Extracting xliff files *" +echo "***************************************" +if [ -e localization.tmp ]; then + rm -rf localization.tmp +fi +# extract xliff file (has to be run multiple times, even if no error occured, don't ask me why) +# we use grep here to test for a dummy string to detect if our run succeeded +dummy="DON'T TRANSLATE: $(head /dev/urandom | LC_ALL=C tr -dc A-Za-z0-9 | head -c 8)" +#echo "\nlet swiftuiTranslationRandomDummyString = Text(\"$dummy\")" >> Classes/SwiftuiHelpers.swift +x=$((1)) +while [[ $x -lt 16 ]]; do + echo "STARTING RUN $x..." + while ! xcrun xcodebuild -workspace "Monal.xcworkspace" -scheme "Monal" -sdk iphoneos -configuration "Beta" -allowProvisioningUpdates -exportLocalizations -localizationPath localization.tmp -exportLanguage base SWIFT_EMIT_LOC_STRINGS="$compile_swift"; do + echo "ERROR, TRYING AGAIN..." + done + echo "RUN $x SUCCEEDED, EXTRACTING STRINGS FROM XLIFF!" + # extract additional strings from xliff file and add them to our strings file (bartycrouch will remove duplicates later on) + ../scripts/xliff_extractor.py -x "localization.tmp/base.xcloc/Localized Contents/base.xliff" + x=$((x+1)) +done +if ! grep -q "$dummy" "localization/external/Base.lproj/Localizable.strings"; then + echo "Could not extract dummy string after $x runs!" + #exit 1 +fi +awk "!/$dummy/" "localization/external/Base.lproj/Localizable.strings" > "localization/external/Base.lproj/Localizable.strings.new" +mv "localization/external/Base.lproj/Localizable.strings.new" "localization/external/Base.lproj/Localizable.strings" +rm -rf *A\ Document\ Being\ Saved\ By\ xcodebuild* + +echo "" +echo "*********************************************************" +echo "* Using batrycrouch to update all languages *" +echo "*********************************************************" +runBartycrouch +if [ -e localization.tmp ]; then + rm -rf localization.tmp +fi + +echo "" +echo "*******************************************" +echo "* Showing results as git diff *" +echo "*******************************************" +for folder in "localization/external" "shareSheet-iOS/localization/external"; do + #subshell to not leak from "cd $folder" + ( + cd $folder + echo "Diff of $folder:" + git diff || true + if [[ $1 != "NOCOMMIT" ]]; then + git add -u + # empty commits should not abort this script + git commit -m "Updated translations via BartyCrouch xliff extractor" || true + git log -n 2 + git remote --verbose + git push + fi + ) +done + +echo "" +echo "***************************************" +echo "* Cleaning up submodules *" +echo "***************************************" +git submodule deinit --all -f +git submodule update --init --recursive + +exit 0 diff --git a/scripts/uploadAlpha.sh b/scripts/uploadAlpha.sh new file mode 100755 index 0000000..696fea5 --- /dev/null +++ b/scripts/uploadAlpha.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +function sftp_upload { +sftp ${1} < build/app/latest.txt +sftp ${1} < 0: + logger.debug("Not parsable, must be comment: %s", line.strip()) + elif parts: + strings[parts.group(1)] = True + if parts.group(1) != parts.group(2): + logger.warning("Strings LHS and RHS don't match: '%s' != '%s'!", parts.group(1), parts.group(2)) + preexisting = len(strings) + logger.info("Adding missing strings data to '%s'...", path) + with open(path, mode="a+", encoding="utf-8") as output: + for unit in file.findall("./def:body/def:trans-unit", ns): + string = unit.attrib["id"].replace("\n", "\\n") + if string not in strings: + comment = "No comment provided by engineer." + if len(unit.find("./def:note", ns).text): + comment = unit.find("./def:note", ns).text.replace("\n", "\\n") + logger.debug("Adding new string (%s): %s", comment, string) + output.write("/* %s */\n" % comment) + output.write("\"%s\" = \"%s\";\n\n" % (string, string)) + added += 1 + else: + duplicates += 1 +logger.info("Done, preexisting: %d, duplicates: %d, added: %d", preexisting, duplicates, added) diff --git a/simulated-push.apns b/simulated-push.apns new file mode 100644 index 0000000..f89cda7 --- /dev/null +++ b/simulated-push.apns @@ -0,0 +1,11 @@ +{ + "Simulator Target Bundle": "G7YU7X7KRJ.SworIM", + "aps": { + "mutable-content":"1", + "alert": { + "title": "Mein Titel", + "body": "Frohe Kunde!" + }, + "sound": "default" + } +} \ No newline at end of file