Merge remote-tracking branch 'upstream/dev' into feature/updated-libquic

pull/1061/head^2
Morgan Pretty 4 weeks ago
commit a05827a8a0

@ -2,18 +2,14 @@
We typically develop against the latest stable version of Xcode.
As of this writing, that's Xcode 12.4
## Prerequistes
Install [CocoaPods](https://guides.cocoapods.org/using/getting-started.html).
As of this writing, that's Xcode 16.2
## 1. Clone
Clone the repo to a working directory:
```
git clone https://github.com/oxen-io/session-ios.git
git clone https://github.com/session-foundation/session-ios.git
```
**Recommendation:**
@ -27,20 +23,30 @@ git clone https://github.com/<USERNAME>/session-ios.git
You can then add the Session repo to sync with upstream changes:
```
git remote add upstream https://github.com/oxen-io/session-ios
git remote add upstream https://github.com/session-foundation/session-ios
```
## 2. Submodules
## 2. Xcode
Session requires a number of submodules to build, these can be retrieved by navigating to the project directory and running:
Open the `Session.xcodeproj` in Xcode.
```
git submodule update --init --recursive
open Session.xcodeproj
```
## 3. libSession build dependencies
In the TARGETS area of the General tab, change the Team dropdown to your own. You will need to do that for all the listed targets, e.g. `Session`, `SessionShareExtension`, and `SessionNotificationServiceExtension`. You will need an Apple Developer account for this.
On the Capabilities tab, turn off Push Notifications and Data Protection, while keeping Background Modes on. The App Groups capability will need to remain on in order to access the shared data storage.
Build and Run and you are ready to go!
The iOS project has a share C++ library called `libSession` which is built as one of the project dependencies, in order for this to compile the following dependencies need to be installed:
## Other
### Building libSession from source
The iOS project has a shared C++ library called `libSession` which is included via Swift Package Manager, it also supports building `libSession` from source (which can be cloned from https://github.com/session-foundation/libsession-util) by using the `Session_CompileLibSession` scheme and updating the `LIB_SESSION_SOURCE_DIR` build setting to point at the `libSession` source directory (currently it's set to `${SOURCE_DIR}/../LibSession-Util`)
In order for this to compile the following dependencies need to be installed:
- cmake
- m4
- pkg-config
@ -51,41 +57,10 @@ Additionally `xcode-select` needs to be setup correctly (depending on the order
`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`
## 4. Xcode
Open the `Session.xcodeproj` in Xcode.
```
open Session.xcodeproj
```
In the TARGETS area of the General tab, change the Team dropdown to
your own. You will need to do that for all the listed targets, e.g.
Session, SessionShareExtension, and SessionNotificationServiceExtension. You
will need an Apple Developer account for this.
On the Capabilities tab, turn off Push Notifications and Data Protection,
while keeping Background Modes on. The App Groups capability will need to
remain on in order to access the shared data storage.
Build and Run and you are ready to go!
## Known issues
### Address & Undefined Behaviour Sanitizer Linker Errors
It seems that there is an open issue with Swift Package Manager (https://github.com/swiftlang/swift-package-manager/issues/4407) where some packages (in our case `libwebp`) run into issues when the Address Sanitizer or Undefined Behaviour Sanitizer are enabled within the scheme, if you see linker errors like the below when building this is likely the issue and can be resolved by disabling these sanitisers.
In order to still benefit from these settings they are explicitly set as `Other C Flags` for the `SessionUtil` target when building in debug mode to enable better debugging of `libSession`.
```
Undefined symbol: ___asan_init
Undefined symbol: ___ubsan_handle_add_overflow
```
### Third-party Installation
The database for the app is stored within an `App Group` directory which is based on the app identifier, unfortunately the identifier cannot be retrieved at runtime so it's currently hard-coded in the code. In order to be able to run session on a device you will need to update the `UserDefaults.applicationGroup` variable in `SessionUtilitiesKit/General/SNUserDefaults` to match the value provided (You may also need to create the `App Group` on your Apple Developer account).
The database for the app is stored within an `App Group` directory which is based on the app identifier, we have a script Build Phase which attempts to extract this and include it in the `Info.plist` for the project so we can access it at runtime (to reduce the manual handling other devs need to do) but if for some reason it's not working the fallback value can be updated within the `UserDefaults.applicationGroup` variable in `SessionUtilitiesKit/Types/UserDefaultsType` to match the value set for your project (You may also need to create the `App Group` on your Apple Developer account).
### Push Notifications
Features related to push notifications are known to be not working for
third-party contributors since Apple's Push Notification service pushes
will only work with the Session production code signing
certificate.
Features related to push notifications are known to be not working for third-party contributors since Apple's Push Notification service pushes will only work with the Session production code signing certificate.

@ -10,7 +10,7 @@ Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about
## Want to contribute? Found a bug or have a feature request?
Please search for any [existing issues](https://github.com/loki-project/session-ios/issues) that describe your bugs in order to avoid duplicate submissions. Submissions can be made by making a pull request to our dev branch. If you don't know where to start contributing, try reading the Github issues page for ideas.
Please search for any [existing issues](https://github.com/session-foundation/session-ios/issues) that describe your bugs in order to avoid duplicate submissions. Submissions can be made by making a pull request to our dev branch. If you don't know where to start contributing, try reading the Github issues page for ideas.
## Build instructions
@ -18,7 +18,46 @@ Build instructions can be found in [BUILDING.md](BUILDING.md).
## Translations
Want to help us translate Session into your language? You can do so at https://crowdin.com/project/session-ios!
Want to help us translate Session into your language? You can do so at https://getsession.org/translate !
## Verifying signatures
**Step 1:**
Add Jason's GPG key. Jason Rhinelander, a member of the [Session Technology Foundation](https://session.foundation/) and is the current signer for all Session iOS releases. His GPG key can be found on his GitHub and other sources.
```sh
wget https://github.com/jagerman.gpg
gpg --import jagerman.gpg
```
**Step 2:**
Get the signed hashes for this release. `SESSION_VERSION` needs to be updated for the release you want to verify.
```sh
export SESSION_VERSION=2.9.1
wget https://github.com/session-foundation/session-ios/releases/download/$SESSION_VERSION/signature.asc
```
**Step 3:**
Verify the signature of the hashes of the files.
```sh
gpg --verify signature.asc 2>&1 |grep "Good signature from"
```
The command above should print "`Good signature from "Jason Rhinelander...`". If it does, the hashes are valid but we still have to make the sure the signed hashes match the downloaded files.
**Step 4:**
Make sure the two commands below return the same hash for the file you are checking. If they do, file is valid.
```
sha256sum session-$SESSION_VERSION.ipa
grep .ipa signature.asc
```
## License

@ -40,7 +40,8 @@ extension ProjectState {
"cameraGrantAccessDescription",
"permissionsAppleMusic",
"permissionsStorageSave",
"permissionsMicrophoneAccessRequiredIos"
"permissionsMicrophoneAccessRequiredIos",
"permissionsLocalNetworkAccessRequiredIos"
]
static let permissionStringsMap: [String: String] = [
"permissionsStorageSend": "NSPhotoLibraryUsageDescription",
@ -48,7 +49,8 @@ extension ProjectState {
"cameraGrantAccessDescription": "NSCameraUsageDescription",
"permissionsAppleMusic": "NSAppleMusicUsageDescription",
"permissionsStorageSave": "NSPhotoLibraryAddUsageDescription",
"permissionsMicrophoneAccessRequiredIos": "NSMicrophoneUsageDescription"
"permissionsMicrophoneAccessRequiredIos": "NSMicrophoneUsageDescription",
"permissionsLocalNetworkAccessRequiredIos": "NSLocalNetworkUsageDescription"
]
static let validSourceSuffixes: Set<String> = [".swift", ".m"]
static let excludedPaths: Set<String> = [
@ -318,7 +320,8 @@ enum ScriptAction: String {
ProjectState.permissionStrings.forEach { key in
guard let nsKey: String = ProjectState.permissionStringsMap[key] else { return }
if
let stringsData: Data = try? JSONSerialization.data(withJSONObject: (projectState.localizationFile.strings[key] as! JSON), options: [ .fragmentsAllowed ]),
let json = projectState.localizationFile.strings[key] as? JSON,
let stringsData: Data = try? JSONSerialization.data(withJSONObject: json, options: [ .fragmentsAllowed ]),
let stringsJSONString: String = String(data: stringsData, encoding: .utf8)
{
let updatedStringsJSONString = stringsJSONString.replacingOccurrences(of: "{app_name}", with: "Session")

@ -163,6 +163,7 @@ REQUIRES_BUILD=0
if [ "${LIB_SESSION_SOURCE_DIR}" != "${OLD_SOURCE_DIR}" ]; then
echo "Build is not up-to-date (source dir change) - removing old build and rebuilding"
rm -rf "${COMPILE_DIR}"
mkdir -p "${COMPILE_DIR}"
REQUIRES_BUILD=1
elif [ "${NEW_SOURCE_HASH}" != "${OLD_SOURCE_HASH}" ]; then
echo "Build is not up-to-date (source change) - creating new build"
@ -234,7 +235,7 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then
submodule_check=ON
build_type="Release"
if [ "$CONFIGURATION" == "Debug" ]; then
if [ "$CONFIGURATION" == "Debug" ] || [ "$CONFIGURATION" == "Debug_Compile_LibSession" ]; then
submodule_check=OFF
build_type="Debug"
fi

File diff suppressed because it is too large Load Diff

@ -1,5 +1,5 @@
{
"originHash" : "e3fdf2f44acd1f05dab295d0c9e3faf05f5e4461d512be1d5a77af42e0a25e48",
"originHash" : "3976430cfdaea7445596ad6123334158bdc83e4997da535d15a15afc3c7aa091",
"pins" : [
{
"identity" : "cocoalumberjack",
@ -55,15 +55,6 @@
"version" : "1.2.1"
}
},
{
"identity" : "libwebp-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libwebp-Xcode.git",
"state" : {
"revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2",
"version" : "1.5.0"
}
},
{
"identity" : "nimble",
"kind" : "remoteSourceControl",
@ -109,15 +100,6 @@
"version" : "107.3.0"
}
},
{
"identity" : "session-ios-yyimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/session-foundation/session-ios-yyimage",
"state" : {
"revision" : "14786afd2523f80be304b377f9dbab6b7904bf02",
"version" : "1.1.0"
}
},
{
"identity" : "session-lucide",
"kind" : "remoteSourceControl",

@ -52,6 +52,8 @@
buildConfiguration = "Debug_Compile_LibSession"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableAddressSanitizer = "YES"
enableASanStackUseAfterReturn = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"

@ -1,7 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import Combine
import CallKit
import GRDB
@ -13,26 +12,27 @@ import SessionUtilitiesKit
import SessionSnodeKit
public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
@objc static let isEnabled = true
private let dependencies: Dependencies
public let webRTCSession: WebRTCSession
var currentConnectionStep: ConnectionStep
var connectionStepsRecord: [Bool]
// MARK: - Metadata Properties
public let uuid: String
public let callId: UUID // This is for CallKit
public let sessionId: String
let mode: CallMode
public let mode: CallMode
let contactName: String
var audioMode: AudioMode
public let webRTCSession: WebRTCSession
let isOutgoing: Bool
var remoteSDP: RTCSessionDescription? = nil
var callInteractionId: Int64?
var remoteSDP: RTCSessionDescription? = nil {
didSet {
if hasStartedConnecting, let sdp = remoteSDP {
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
}
}
}
var answerCallAction: CXAnswerCallAction? = nil
let contactName: String
let profilePicture: UIImage
let animatedProfilePicture: YYImage?
// MARK: - Control
lazy public var videoCapturer: RTCVideoCapturer = {
@ -87,7 +87,9 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
didSet {
stateDidChange?()
hasConnectedDidChange?()
updateCallDetailedStatus?("Call Connected")
updateCurrentConnectionStepIfPossible(
mode == .offer ? OfferStep.connected : AnswerStep.connected
)
}
}
@ -152,29 +154,17 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
// MARK: - Initialization
init(_ db: Database, for sessionId: String, uuid: String, mode: CallMode, outgoing: Bool = false, using dependencies: Dependencies) {
init(for sessionId: String, contactName: String, uuid: String, mode: CallMode, using dependencies: Dependencies) {
self.dependencies = dependencies
self.sessionId = sessionId
self.contactName = contactName
self.uuid = uuid
self.callId = UUID()
self.mode = mode
self.audioMode = .earpiece
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid, using: dependencies)
self.isOutgoing = outgoing
let avatarData: Data? = dependencies[singleton: .displayPictureManager].displayPicture(db, id: .user(sessionId))
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact, using: dependencies)
self.profilePicture = avatarData
.map { UIImage(data: $0) }
.defaulting(to: PlaceholderIcon.generate(seed: sessionId, text: self.contactName, size: 300))
self.animatedProfilePicture = avatarData
.map { data -> YYImage? in
switch data.guessedImageFormat {
case .gif, .webp: return YYImage(data: data)
default: return nil
}
}
self.currentConnectionStep = (mode == .offer ? OfferStep.initializing : AnswerStep.receivedOffer)
self.connectionStepsRecord = [Bool](repeating: false, count: (mode == .answer ? 5 : 6))
WebRTCSession.current = self.webRTCSession
self.webRTCSession.delegate = self
@ -209,8 +199,8 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
Log.info(.calls, "Did receive remote sdp.")
remoteSDP = sdp
if hasStartedConnecting {
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
if mode == .answer {
self.updateCurrentConnectionStepIfPossible(AnswerStep.receivedOffer)
}
}
@ -251,9 +241,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
)
.inserted(db)
self.callInteractionId = interaction?.id
self.updateCallDetailedStatus?("Creating Call")
self.updateCurrentConnectionStepIfPossible(OfferStep.initializing)
try? webRTCSession
.sendPreOffer(
@ -266,7 +254,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
// Start the timeout timer for the call
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
.flatMap { [weak self] _ in
self?.updateCallDetailedStatus?("Sending Call Offer")
self?.updateCurrentConnectionStepIfPossible(OfferStep.sendingOffer)
return webRTCSession
.sendOffer(to: thread)
.retry(5)
@ -276,7 +264,6 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
switch result {
case .finished:
Log.info(.calls, "Offer message sent")
self?.updateCallDetailedStatus?("Sending Connection Candidates")
case .failure(let error):
Log.error(.calls, "Error initializing call after 5 retries: \(error), ending call...")
self?.handleCallInitializationFailed()
@ -289,10 +276,10 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
guard case .answer = mode else { return }
hasStartedConnecting = true
self.updateCurrentConnectionStepIfPossible(AnswerStep.sendingAnswer)
if let sdp = remoteSDP {
Log.info(.calls, "Got remote sdp already")
self.updateCallDetailedStatus?("Answering Call")
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
}
}
@ -305,33 +292,31 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
func endSessionCall() {
guard !hasEnded else { return }
let sessionId: String = self.sessionId
webRTCSession.hangUp()
dependencies[singleton: .storage].writeAsync { [weak self] db in
try self?.webRTCSession.endCall(db, with: sessionId)
dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [webRTCSession, sessionId] in
webRTCSession.endCall(with: sessionId)
}
hasEnded = true
}
func handleCallInitializationFailed() {
self.endSessionCall()
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: nil)
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .failed)
}
// MARK: - Call Message Handling
public func updateCallMessage(mode: EndCallMode, using dependencies: Dependencies) {
guard let callInteractionId: Int64 = callInteractionId else { return }
let duration: TimeInterval = self.duration
let hasStartedConnecting: Bool = self.hasStartedConnecting
dependencies[singleton: .storage].writeAsync(
updates: { db in
guard let interaction: Interaction = try? Interaction.fetchOne(db, id: callInteractionId) else {
updates: { [sessionId, uuid] db in
guard let interaction: Interaction = try? Interaction
.filter(Interaction.Columns.threadId == sessionId)
.filter(Interaction.Columns.messageUuid == uuid)
.fetchOne(db)
else {
return
}
@ -432,15 +417,29 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
isRemoteVideoEnabled = isEnabled
}
public func iceCandidateDidSend() {
public func sendingIceCandidates() {
DispatchQueue.main.async {
self.updateCallDetailedStatus?("Awaiting Recipient Answer...")
self.updateCurrentConnectionStepIfPossible(
self.mode == .offer ? OfferStep.sendingIceCandidates : AnswerStep.sendingIceCandidates
)
}
}
public func iceCandidateDidSend() {
if self.mode == .offer {
DispatchQueue.main.async {
self.updateCurrentConnectionStepIfPossible(
self.mode == .offer ? OfferStep.waitingForAnswer : AnswerStep.handlingIceCandidates
)
}
}
}
public func iceCandidateDidReceive() {
DispatchQueue.main.async {
self.updateCallDetailedStatus?("Handling Connection Candidates")
self.updateCurrentConnectionStepIfPossible(
self.mode == .offer ? OfferStep.handlingIceCandidates : AnswerStep.handlingIceCandidates
)
}
}
@ -465,13 +464,14 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
}
public func reconnectIfNeeded() {
guard !self.hasEnded else { return }
setupTimeoutTimer()
hasStartedReconnecting?()
guard isOutgoing else { return }
tryToReconnect()
}
private func tryToReconnect() {
guard self.mode == .offer else { return }
reconnectTimer?.invalidate()
// Register a callback to get the current network status then remove it immediately as we only
@ -526,3 +526,105 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
timeOutTimer = nil
}
}
// MARK: - Connection Steps
// TODO: Remove these when calls are out of beta
extension SessionCall {
// stringlint:ignore_contents
public static let call_connection_steps_sender: [String] = [
"Creating Call 1/6",
"Sending Call Offer 2/6",
"Sending Connection Candidates 3/6",
"Awaiting Recipient Answer... 4/6",
"Handling Connection Candidates 5/6",
"Call Connected 6/6",
]
// stringlint:ignore_contents
public static let call_connection_steps_receiver: [String] = [
"Received Call Offer 1/5",
"Answering Call 2/5",
"Sending Connection Candidates 3/5",
"Handling Connection Candidates 4/5",
"Call Connected 5/5",
]
public protocol ConnectionStep {
var index: Int { get }
var nextStep: ConnectionStep? { get }
}
public enum OfferStep: ConnectionStep {
case initializing
case sendingOffer
case sendingIceCandidates
case waitingForAnswer
case handlingIceCandidates
case connected
public var index: Int {
switch self {
case .initializing: return 0
case .sendingOffer: return 1
case .sendingIceCandidates: return 2
case .waitingForAnswer: return 3
case .handlingIceCandidates: return 4
case .connected: return 5
}
}
public var nextStep: ConnectionStep? {
switch self {
case .initializing: return OfferStep.sendingOffer
case .sendingOffer: return OfferStep.sendingIceCandidates
case .sendingIceCandidates: return OfferStep.waitingForAnswer
case .waitingForAnswer: return OfferStep.handlingIceCandidates
case .handlingIceCandidates: return OfferStep.connected
case .connected: return nil
}
}
}
public enum AnswerStep: ConnectionStep {
case receivedOffer
case sendingAnswer
case sendingIceCandidates
case handlingIceCandidates
case connected
public var index: Int {
switch self {
case .receivedOffer: return 0
case .sendingAnswer: return 1
case .sendingIceCandidates: return 2
case .handlingIceCandidates: return 3
case .connected: return 4
}
}
public var nextStep: ConnectionStep? {
switch self {
case .receivedOffer: return AnswerStep.sendingAnswer
case .sendingAnswer: return AnswerStep.sendingIceCandidates
case .sendingIceCandidates: return AnswerStep.handlingIceCandidates
case .handlingIceCandidates: return AnswerStep.connected
case .connected: return nil
}
}
}
internal func updateCurrentConnectionStepIfPossible(_ step: ConnectionStep) {
connectionStepsRecord[step.index] = true
while let nextStep = currentConnectionStep.nextStep, connectionStepsRecord[nextStep.index] {
currentConnectionStep = nextStep
DispatchQueue.main.async {
self.updateCallDetailedStatus?(
self.mode == .offer ?
SessionCall.call_connection_steps_sender[self.currentConnectionStep.index] :
SessionCall.call_connection_steps_receiver[self.currentConnectionStep.index]
)
}
}
}
}

@ -49,7 +49,7 @@ extension SessionCallManager {
reportCurrentCallEnded(reason: .unanswered)
}
else {
reportCurrentCallEnded(reason: nil)
reportCurrentCallEnded(reason: .declinedElsewhere)
}
return true

@ -78,7 +78,7 @@ extension SessionCallManager: CXProviderDelegate {
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
call.webRTCSession.audioSessionDidActivate(audioSession)
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
if call.mode == .offer && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
}
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {

@ -135,7 +135,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
}
}
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
public func reportCurrentCallEnded(reason: CXCallEndedReason) {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.reportCurrentCallEnded(reason: reason)
@ -143,41 +143,23 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
return
}
func handleCallEnded() {
Log.info(.calls, "Call ended.")
WebRTCSession.current = nil
dependencies[defaults: .appGroup, key: .isCallOngoing] = false
dependencies[defaults: .appGroup, key: .lastCallPreOffer] = nil
if dependencies[singleton: .appContext].isInBackground {
(UIApplication.shared.delegate as? AppDelegate)?.stopPollers()
Log.flush()
}
}
guard let call = currentCall else {
handleCallEnded()
suspendDatabaseIfCallEndedInBackground()
self.cleanUpPreviousCall()
self.suspendDatabaseIfCallEndedInBackground()
return
}
if let reason = reason {
self.provider?.reportCall(with: call.callId, endedAt: nil, reason: reason)
switch (reason) {
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere, using: dependencies)
case .unanswered: call.updateCallMessage(mode: .unanswered, using: dependencies)
case .declinedElsewhere: call.updateCallMessage(mode: .local, using: dependencies)
default: call.updateCallMessage(mode: .remote, using: dependencies)
}
}
else {
call.updateCallMessage(mode: .local, using: dependencies)
self.provider?.reportCall(with: call.callId, endedAt: nil, reason: reason)
switch (reason) {
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere, using: dependencies)
case .unanswered: call.updateCallMessage(mode: .unanswered, using: dependencies)
case .declinedElsewhere: call.updateCallMessage(mode: .local, using: dependencies)
default: call.updateCallMessage(mode: .remote, using: dependencies)
}
(call as? SessionCall)?.webRTCSession.dropConnection()
self.currentCall = nil
handleCallEnded()
self.cleanUpPreviousCall()
self.suspendDatabaseIfCallEndedInBackground()
}
public func currentWebRTCSessionMatches(callId: String) -> Bool {
@ -208,9 +190,11 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
if dependencies[singleton: .appContext].isInBackground {
// Stop all jobs except for message sending and when completed suspend the database
dependencies[singleton: .jobRunner].stopAndClearPendingJobs(exceptForVariant: .messageSend) { [dependencies] _ in
dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() }
dependencies[singleton: .storage].suspendDatabaseAccess()
Log.flush()
if self.currentCall?.hasEnded != false {
dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() }
dependencies[singleton: .storage].suspendDatabaseAccess()
Log.flush()
}
}
}
}
@ -220,11 +204,21 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
public func showCallUIForCall(caller: String, uuid: String, mode: CallMode, interactionId: Int64?) {
guard
let call: SessionCall = dependencies[singleton: .storage].read({ [dependencies] db in
SessionCall(db, for: caller, uuid: uuid, mode: mode, using: dependencies)
SessionCall(
for: caller,
contactName: Profile.displayName(
db,
id: caller,
threadVariant: .contact,
using: dependencies
),
uuid: uuid,
mode: mode,
using: dependencies
)
})
else { return }
call.callInteractionId = interactionId
call.reportIncomingCallIfNeeded { [dependencies] error in
if let error = error {
Log.error(.calls, "Failed to report incoming call to CallKit due to error: \(error)")
@ -234,18 +228,18 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
DispatchQueue.main.async {
guard
dependencies[singleton: .appContext].isMainAppAndActive,
let presentingVC: UIViewController = dependencies[singleton: .appContext].frontMostViewController
let currentFrontMostViewController: UIViewController = dependencies[singleton: .appContext].frontMostViewController
else { return }
if
let conversationVC: ConversationVC = (presentingVC as? TopBannerController)?.wrappedViewController() as? ConversationVC,
let conversationVC: ConversationVC = currentFrontMostViewController as? ConversationVC,
conversationVC.viewModel.threadData.threadId == call.sessionId
{
let callVC = CallVC(for: call, using: dependencies)
callVC.conversationVC = conversationVC
conversationVC.resignFirstResponder()
conversationVC.hideInputAccessoryView()
presentingVC.present(callVC, animated: true, completion: nil)
currentFrontMostViewController.present(callVC, animated: true, completion: nil)
}
else if !Preferences.isCallKitSupported {
let incomingCallBanner = IncomingCallBanner(for: call, using: dependencies)
@ -298,4 +292,21 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
(dependencies[singleton: .appContext].frontMostViewController as? CallVC)?.handleEndCallMessage()
MiniCallView.current?.dismiss()
}
public func cleanUpPreviousCall() {
Log.info(.calls, "Clean up calls")
WebRTCSession.current?.dropConnection()
WebRTCSession.current = nil
currentCall = nil
dependencies[defaults: .appGroup, key: .isCallOngoing] = false
dependencies[defaults: .appGroup, key: .lastCallPreOffer] = nil
if dependencies[singleton: .appContext].isNotInForeground {
dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [dependencies] in
dependencies[singleton: .currentUserPoller].stop()
}
Log.flush()
}
}
}

@ -1,22 +1,22 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import MediaPlayer
import AVKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
final class CallVC: UIViewController, VideoPreviewDelegate {
final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDelegate {
private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120)
private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80)
private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173)
private static let minimizeButtonSize: CGFloat = 60
private let dependencies: Dependencies
let call: SessionCall
var latestKnownAudioOutputDeviceName: String?
var durationTimer: Timer?
var duration: Int = 0
var shouldRestartCamera = true
weak var conversationVC: ConversationVC? = nil
@ -129,9 +129,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
return result
}()
private lazy var profilePictureView: UIImageView = {
public lazy var profilePictureView: UIImageView = {
let result = UIImageView()
result.image = self.call.profilePicture
result.set(.width, to: CallVC.avatarRadius * 2)
result.set(.height, to: CallVC.avatarRadius * 2)
result.layer.cornerRadius = CallVC.avatarRadius
@ -141,15 +140,13 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
return result
}()
private lazy var animatedImageView: YYAnimatedImageView = {
let result: YYAnimatedImageView = YYAnimatedImageView()
result.image = self.call.animatedProfilePicture
private lazy var animatedImageView: AnimatedImageView = {
let result: AnimatedImageView = AnimatedImageView()
result.set(.width, to: CallVC.avatarRadius * 2)
result.set(.height, to: CallVC.avatarRadius * 2)
result.layer.cornerRadius = CallVC.avatarRadius
result.layer.masksToBounds = true
result.contentMode = .scaleAspectFill
result.isHidden = (self.call.animatedProfilePicture == nil)
return result
}()
@ -165,8 +162,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
result.addTarget(self, action: #selector(minimize), for: UIControl.Event.touchUpInside)
result.isHidden = !call.hasConnected
result.set(.width, to: 60)
result.set(.height, to: 60)
result.set(.width, to: Self.minimizeButtonSize)
result.set(.height, to: Self.minimizeButtonSize)
return result
}()
@ -276,11 +273,20 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
return result
}()
private lazy var volumeView: MPVolumeView = {
let result = MPVolumeView()
result.showsVolumeSlider = false
result.showsRouteButton = true
result.setRouteButtonImage(
private lazy var routePickerView: AVRoutePickerView = {
let result = AVRoutePickerView()
result.delegate = self
result.alpha = 0
result.layer.cornerRadius = 30
result.set(.width, to: 60)
result.set(.height, to: 60)
return result
}()
private lazy var routePickerButton: UIButton = {
let result = UIButton(type: .custom)
result.setImage(
UIImage(named: "Speaker")?
.withRenderingMode(.alwaysTemplate),
for: .normal
@ -288,6 +294,20 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
result.themeTintColor = .textPrimary
result.themeBackgroundColor = .backgroundSecondary
result.layer.cornerRadius = 30
result.addTarget(self, action: #selector(switchRoute), for: UIControl.Event.touchUpInside)
result.set(.width, to: 60)
result.set(.height, to: 60)
return result
}()
private lazy var routePickerContainer: UIView = {
let result = UIView()
result.addSubview(routePickerView)
routePickerView.pin(to: result)
result.addSubview(routePickerButton)
routePickerButton.pin(to: result)
result.layer.cornerRadius = 30
result.set(.width, to: 60)
result.set(.height, to: 60)
@ -295,7 +315,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
}()
private lazy var operationPanel: UIStackView = {
let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, volumeView])
let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, routePickerContainer])
result.axis = .horizontal
result.spacing = Values.veryLargeSpacing
@ -358,12 +378,12 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
super.init(nibName: nil, bundle: nil)
setupStateChangeCallbacks()
setUpStateChangeCallbacks()
self.modalPresentationStyle = .overFullScreen
self.modalTransitionStyle = .crossDissolve
}
func setupStateChangeCallbacks() {
func setUpStateChangeCallbacks() {
self.call.remoteVideoStateDidChange = { isEnabled in
DispatchQueue.main.async {
UIView.animate(withDuration: 0.25) {
@ -451,24 +471,35 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
view.themeBackgroundColor = .backgroundPrimary
setUpViewHierarchy()
setUpProfilePictureImage()
if shouldRestartCamera { cameraManager.prepare() }
_ = call.videoCapturer // Force the lazy var to instantiate
titleLabel.text = self.call.contactName
dependencies[singleton: .callManager].startCall(call) { [weak self] error in
DispatchQueue.main.async {
if let _ = error {
self?.callInfoLabel.text = "callsErrorStart".localized()
self?.endCall()
}
else {
self?.callInfoLabel.text = "callsRinging".localized()
self?.answerButton.isHidden = true
if self.call.hasConnected {
callDurationLabel.isHidden = false
durationTimer?.invalidate()
durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.updateDuration()
}
} else {
callDurationLabel.isHidden = true
dependencies[singleton: .callManager].startCall(call) { [weak self] error in
DispatchQueue.main.async {
if let _ = error {
self?.callInfoLabel.text = "callsErrorStart".localized()
self?.endCall()
}
else {
self?.callInfoLabel.text = "callsRinging".localized()
self?.answerButton.isHidden = true
}
}
}
}
setupOrientationMonitoring()
setUpOrientationMonitoring()
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteDidChange), name: AVAudioSession.routeChangeNotification, object: nil)
}
@ -509,8 +540,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
view.addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.center(.vertical, in: minimizeButton)
titleLabel.pin(.leading, to: .leading, of: view, withInset: Values.largeSpacing)
titleLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.largeSpacing)
titleLabel.pin(.leading, to: .trailing, of: minimizeButton, withInset: Values.smallSpacing)
titleLabel.pin(.trailing, to: .trailing, of: view, withInset: -(Values.smallSpacing + Self.minimizeButtonSize))
// Response Panel
view.addSubview(responsePanel)
@ -545,6 +576,33 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
callDurationLabel.center(in: callInfoLabelContainer)
}
func setUpProfilePictureImage() {
let avatarData: Data? = dependencies[singleton: .storage].read { [call, dependencies] db in
dependencies[singleton: .displayPictureManager].displayPicture(db, id: .user(call.sessionId))
}
self.profilePictureView.image = avatarData
.map { UIImage(data: $0) }
.defaulting(to: PlaceholderIcon.generate(seed: call.sessionId, text: call.contactName, size: 300))
let maybeAnimatedProfilePicture = avatarData
.map { data -> Data? in
switch data.guessedImageFormat {
case .gif, .webp: return data
default: return nil
}
}
if let animatedProfilePicture = maybeAnimatedProfilePicture {
self.animatedImageView.loadAnimatedImage(from: animatedProfilePicture)
self.animatedImageView.isHidden = false
self.profilePictureView.isHidden = true
} else {
self.animatedImageView.isHidden = true
self.profilePictureView.isHidden = false
}
}
private func addFloatingVideoView() {
guard let window: UIWindow = dependencies[singleton: .appContext].mainWindow else { return }
@ -574,7 +632,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
// MARK: - Orientation
private func setupOrientationMonitoring() {
private func setUpOrientationMonitoring() {
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
NotificationCenter.default.addObserver(self, selector: #selector(didChangeDeviceOrientation), name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
}
@ -591,7 +649,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
self.switchAudioButton.transform = transform
self.switchCameraButton.transform = transform
self.videoButton.transform = transform
self.volumeView.transform = transform
self.routePickerContainer.transform = transform
}
}
@ -648,7 +706,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
dependencies[singleton: .callManager].endCall(call) { [weak self, dependencies] error in
if let _ = error {
self?.call.endSessionCall()
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: nil)
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .declinedElsewhere)
}
Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in
@ -664,8 +722,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
// stringlint:ignore_contents
@objc private func updateDuration() {
guard let connectedDate = call.connectedDate else { return }
let duration = Int(Date().timeIntervalSince1970 - connectedDate.timeIntervalSince1970)
callDurationLabel.text = String(format: "%.2d:%.2d", duration/60, duration%60)
duration += 1
}
// MARK: - Minimize to a floating view
@ -693,7 +752,22 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
call.isVideoEnabled = false
}
else {
guard Permissions.requestCameraPermissionIfNeeded(using: dependencies) else { return }
guard Permissions.requestCameraPermissionIfNeeded(using: dependencies) else {
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "permissionsRequired".localized(),
body: .text("permissionsCameraAccessRequiredCallsIos".localized()),
showCondition: .disabled,
confirmTitle: "sessionSettings".localized(),
onConfirm: { _ in
UIApplication.shared.openSystemSettings()
}
)
)
self.navigationController?.present(confirmationModal, animated: true, completion: nil)
return
}
let previewVC = VideoPreviewVC()
previewVC.delegate = self
present(previewVC, animated: true, completion: nil)
@ -757,6 +831,17 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
}
}
@objc private func switchRoute() {
simulateRoutePickerViewTapping()
}
private func simulateRoutePickerViewTapping() {
guard let routeButton = routePickerView.subviews.first(where: { $0 is UIButton }) as? UIButton else {
return
}
routeButton.sendActions(for: .touchUpInside)
}
@objc private func audioRouteDidChange() {
let currentSession = AVAudioSession.sharedInstance()
let currentRoute = currentSession.currentRoute
@ -768,35 +853,35 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
switch currentOutput.portType {
case .builtInSpeaker:
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary
routePickerButton.setImage(image, for: .normal)
routePickerButton.themeTintColor = .backgroundSecondary
routePickerButton.themeBackgroundColor = .textPrimary
case .headphones:
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary
routePickerButton.setImage(image, for: .normal)
routePickerButton.themeTintColor = .backgroundSecondary
routePickerButton.themeBackgroundColor = .textPrimary
case .bluetoothLE: fallthrough
case .bluetoothA2DP:
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary
routePickerButton.setImage(image, for: .normal)
routePickerButton.themeTintColor = .backgroundSecondary
routePickerButton.themeBackgroundColor = .textPrimary
case .bluetoothHFP:
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary
routePickerButton.setImage(image, for: .normal)
routePickerButton.themeTintColor = .backgroundSecondary
routePickerButton.themeBackgroundColor = .textPrimary
case .builtInReceiver: fallthrough
default:
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary
routePickerButton.setImage(image, for: .normal)
routePickerButton.themeTintColor = .textPrimary
routePickerButton.themeBackgroundColor = .backgroundSecondary
}
}
}
@ -810,4 +895,14 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
self.callDurationLabel.alpha = isHidden ? 1 : 0
}
}
// MARK: - AVRoutePickerViewDelegate
func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) {
}
func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {
}
}

@ -73,10 +73,24 @@ final class CallMissedTipsModal: Modal {
// MARK: - Lifecycle
init(caller: String) {
init(caller: String, presentingViewController: UIViewController?, using dependencies: Dependencies) {
self.caller = caller
super.init()
super.init(
afterClosed: {
let navController: UINavigationController = StyledNavigationController(
rootViewController: SessionTableViewController(
viewModel: PrivacySettingsViewModel(
shouldShowCloseButton: true,
shouldAutomaticallyShowCallModal: true,
using: dependencies
)
)
)
navController.modalPresentationStyle = .fullScreen
presentingViewController?.present(navController, animated: true, completion: nil)
}
)
self.modalPresentationStyle = .overFullScreen
self.modalTransitionStyle = .crossDissolve
@ -87,7 +101,7 @@ final class CallMissedTipsModal: Modal {
}
override func populateContentView() {
cancelButton.setTitle("okay".localized(), for: .normal)
cancelButton.setTitle("sessionSettings".localized(), for: .normal)
contentView.addSubview(mainStackView)
tipsIconContainerView.addSubview(tipsIconImageView)

@ -206,7 +206,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
dependencies[singleton: .callManager].endCall(call) { [weak self, dependencies] error in
if let _ = error {
self?.call.endSessionCall()
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: nil)
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .declinedElsewhere)
}
self?.dismiss()

@ -135,7 +135,7 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 32
imageView.contentMode = .scaleAspectFill
imageView.image = callVC.call.profilePicture
imageView.image = callVC.profilePictureView.image
result.addSubview(imageView)
imageView.set(.width, to: 64)
imageView.set(.height, to: 64)
@ -161,7 +161,7 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
dependencies[singleton: .callManager].endCall(callVC.call) { [callVC, dependencies] error in
if let _ = error {
callVC.call.endSessionCall()
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: nil)
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .failed)
}
}
return
@ -196,7 +196,7 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
self?.callVC.call.removeRemoteVideoRenderer(remoteVideoView)
}
self?.callVC.setupStateChangeCallbacks()
self?.callVC.setUpStateChangeCallbacks()
MiniCallView.current = nil
self?.removeFromSuperview()
})

@ -24,9 +24,11 @@ extension WebRTCSession {
else {
guard sdp.type == .offer else { return }
self?.sendAnswer(to: sessionId)
.retry(5)
.sinkUntilComplete()
DispatchQueue.global(qos: .userInitiated).async {
self?.sendAnswer(to: sessionId)
.retry(5)
.sinkUntilComplete()
}
}
})
}

@ -13,6 +13,7 @@ public protocol WebRTCSessionDelegate: AnyObject {
func webRTCIsConnected()
func isRemoteVideoDidChange(isEnabled: Bool)
func sendingIceCandidates()
func iceCandidateDidSend()
func iceCandidateDidReceive()
func dataChannelDidOpen()
@ -108,6 +109,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
self.dependencies = dependencies
super.init()
Log.info(.calls, "ICE Severs: \(defaultICEServer?.urls ?? [])")
let mediaStreamTrackIDS = [Self.Constants.media_stream_track_id]
@ -301,6 +303,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
}
private func sendICECandidates() {
self.delegate?.sendingIceCandidates()
let candidates: [RTCIceCandidate] = self.queuedICECandidates
let uuid: String = self.uuid
let contactSessionId: String = self.contactSessionId
@ -360,38 +363,50 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
)
}
public func endCall(
_ db: Database,
with sessionId: String
) throws {
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: sessionId) else { return }
Log.info(.calls, "Sending end call message.")
try MessageSender
.preparedSend(
db,
message: CallMessage(
uuid: self.uuid,
kind: .endCall,
sdps: []
)
.with(try? thread.disappearingMessagesConfiguration
.fetchOne(db)?
.forcedWithDisappearAfterReadIfNeeded()
),
to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant),
namespace: try Message.Destination
.from(db, threadId: thread.id, threadVariant: thread.variant)
.defaultNamespace,
interactionId: nil,
fileIds: [],
using: dependencies
public func endCall(with sessionId: String) {
return dependencies[singleton: .storage]
.writePublisher { [dependencies, uuid] db -> Network.PreparedRequest<Void> in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: sessionId) else {
throw WebRTCSessionError.noThread
}
Log.info(.calls, "Sending end call message.")
return try MessageSender
.preparedSend(
db,
message: CallMessage(
uuid: uuid,
kind: .endCall,
sdps: []
)
.with(try? thread.disappearingMessagesConfiguration
.fetchOne(db)?
.forcedWithDisappearAfterReadIfNeeded()
),
to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant),
namespace: try Message.Destination
.from(db, threadId: thread.id, threadVariant: thread.variant)
.defaultNamespace,
interactionId: nil,
fileIds: [],
using: dependencies
)
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.flatMap { [dependencies] preparedRequest in
preparedRequest.send(using: dependencies).retry(5)
}
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished:
Log.info(.calls, "End call message sent")
case .failure(let error):
Log.error(.calls, "Error sending End call message due to error: \(error)")
}
}
)
.send(using: dependencies)
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
.retry(5)
.sinkUntilComplete()
}
public func dropConnection() {
@ -462,13 +477,15 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
}
extension WebRTCSession {
public func configureAudioSession(outputAudioPort: AVAudioSession.PortOverride = .none) {
public func configureAudioSession() {
let audioSession = RTCAudioSession.sharedInstance()
audioSession.lockForConfiguration()
do {
try audioSession.setCategory(AVAudioSession.Category.playAndRecord)
try audioSession.setMode(AVAudioSession.Mode.voiceChat)
try audioSession.overrideOutputAudioPort(outputAudioPort)
try audioSession.setCategory(
.playAndRecord,
mode: .videoChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
try audioSession.setActive(true)
} catch let error {
Log.error(.calls, "Couldn't set up WebRTC audio session due to error: \(error)")

@ -3,7 +3,6 @@
import Foundation
import Combine
import GRDB
import YYImage
import DifferenceKit
import SessionUIKit
import SessionSnodeKit

@ -276,6 +276,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
identifier: "Contact"
)
),
tableSize: tableView.bounds.size,
using: dependencies
)

@ -15,11 +15,13 @@ extension ContextMenuVC {
struct Action {
let icon: UIImage?
let title: String
let feedback: String?
let expirationInfo: ExpirationInfo?
let themeColor: ThemeValue
let actionType: ActionType
let shouldDismissInfoScreen: Bool
let accessibilityLabel: String?
let work: () -> Void
let work: ((() -> Void)?) -> Void
enum ActionType {
case emoji
@ -33,17 +35,21 @@ extension ContextMenuVC {
init(
icon: UIImage? = nil,
title: String = "",
feedback: String? = nil,
expirationInfo: ExpirationInfo? = nil,
themeColor: ThemeValue = .textPrimary,
actionType: ActionType = .generic,
shouldDismissInfoScreen: Bool = false,
accessibilityLabel: String? = nil,
work: @escaping () -> Void
work: @escaping ((() -> Void)?) -> Void
) {
self.icon = icon
self.title = title
self.feedback = feedback
self.expirationInfo = expirationInfo
self.themeColor = themeColor
self.actionType = actionType
self.shouldDismissInfoScreen = shouldDismissInfoScreen
self.accessibilityLabel = accessibilityLabel
self.work = work
}
@ -55,7 +61,7 @@ extension ContextMenuVC {
icon: UIImage(named: "ic_info"),
title: "info".localized(),
accessibilityLabel: "Message info"
) { delegate?.info(cellViewModel) }
) { _ in delegate?.info(cellViewModel) }
}
static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
@ -66,31 +72,34 @@ extension ContextMenuVC {
"resend".localized()
),
accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message")
) { delegate?.retry(cellViewModel) }
) { completion in delegate?.retry(cellViewModel, completion: completion) }
}
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_reply"),
title: "reply".localized(),
shouldDismissInfoScreen: true,
accessibilityLabel: "Reply to message"
) { delegate?.reply(cellViewModel) }
) { completion in delegate?.reply(cellViewModel, completion: completion) }
}
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_copy"),
title: "copy".localized(),
feedback: "copied".localized(),
accessibilityLabel: "Copy text"
) { delegate?.copy(cellViewModel) }
) { completion in delegate?.copy(cellViewModel, completion: completion) }
}
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_copy"),
title: "accountIDCopy".localized(),
feedback: "copied".localized(),
accessibilityLabel: "Copy Session ID"
) { delegate?.copySessionID(cellViewModel) }
) { completion in delegate?.copySessionID(cellViewModel, completion: completion) }
}
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
@ -102,16 +111,18 @@ extension ContextMenuVC {
expiresInSeconds: cellViewModel.expiresInSeconds
),
themeColor: .danger,
shouldDismissInfoScreen: true,
accessibilityLabel: "Delete message"
) { delegate?.delete(cellViewModel) }
) { completion in delegate?.delete(cellViewModel, completion: completion) }
}
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_download"),
title: "save".localized(),
feedback: "saved".localized(),
accessibilityLabel: "Save attachment"
) { delegate?.save(cellViewModel) }
) { completion in delegate?.save(cellViewModel, completion: completion) }
}
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
@ -120,7 +131,7 @@ extension ContextMenuVC {
title: "banUser".localized(),
themeColor: .danger,
accessibilityLabel: "Ban user"
) { delegate?.ban(cellViewModel) }
) { completion in delegate?.ban(cellViewModel, completion: completion) }
}
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
@ -128,28 +139,29 @@ extension ContextMenuVC {
icon: UIImage(named: "ic_block"),
title: "banDeleteAll".localized(),
themeColor: .danger,
shouldDismissInfoScreen: true,
accessibilityLabel: "Ban user and delete"
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
) { completion in delegate?.banAndDeleteAllMessages(cellViewModel, completion: completion) }
}
static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
title: emoji.rawValue,
actionType: .emoji
) { delegate?.react(cellViewModel, with: emoji) }
) { _ in delegate?.react(cellViewModel, with: emoji) }
}
static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
actionType: .emojiPlus,
accessibilityLabel: "Add emoji"
) { delegate?.showFullEmojiKeyboard(cellViewModel) }
) { _ in delegate?.showFullEmojiKeyboard(cellViewModel) }
}
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
actionType: .dismiss
) { delegate?.contextMenuDismissed() }
) { _ in delegate?.contextMenuDismissed() }
}
}
@ -295,14 +307,14 @@ extension ContextMenuVC {
protocol ContextMenuActionDelegate {
func info(_ cellViewModel: MessageViewModel)
func retry(_ cellViewModel: MessageViewModel)
func reply(_ cellViewModel: MessageViewModel)
func copy(_ cellViewModel: MessageViewModel)
func copySessionID(_ cellViewModel: MessageViewModel)
func delete(_ cellViewModel: MessageViewModel)
func save(_ cellViewModel: MessageViewModel)
func ban(_ cellViewModel: MessageViewModel)
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func ban(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel)
func contextMenuDismissed()

@ -161,7 +161,7 @@ extension ContextMenuVC {
}
@objc private func handleTap() {
action.work()
action.work() {}
dismissWithTimerInvalidationIfNeeded()
}

@ -48,7 +48,7 @@ extension ContextMenuVC {
// MARK: - Interaction
@objc private func handleTap() {
action.work()
action.work() {}
dismiss()
}
}
@ -106,7 +106,7 @@ extension ContextMenuVC {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in
self?.action?.work()
self?.action?.work() {}
})
}
}

@ -415,7 +415,7 @@ final class ContextMenuVC: UIViewController {
},
completion: { [weak self] _ in
self?.dismiss()
self?.actions.first(where: { $0.actionType == .dismiss })?.work()
self?.actions.first(where: { $0.actionType == .dismiss })?.work(){}
}
)
}

@ -110,7 +110,6 @@ extension ConversationVC:
// MARK: - Call
@objc func startCall(_ sender: Any?) {
guard SessionCall.isEnabled else { return }
guard viewModel.threadData.threadIsBlocked == false else { return }
guard viewModel.dependencies[singleton: .storage, key: .areCallsEnabled] else {
let confirmationModal: ConfirmationModal = ConfirmationModal(
@ -125,6 +124,7 @@ extension ConversationVC:
rootViewController: SessionTableViewController(
viewModel: PrivacySettingsViewModel(
shouldShowCloseButton: true,
shouldAutomaticallyShowCallModal: true,
using: dependencies
)
)
@ -139,27 +139,55 @@ extension ConversationVC:
return
}
Permissions.requestMicrophonePermissionIfNeeded(using: viewModel.dependencies)
guard Permissions.microphone == .granted else {
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "permissionsRequired".localized(),
body: .text("permissionsMicrophoneAccessRequiredCallsIos".localized()),
showCondition: .disabled,
confirmTitle: "sessionSettings".localized(),
onConfirm: { _ in
UIApplication.shared.openSystemSettings()
}
)
)
self.navigationController?.present(confirmationModal, animated: true, completion: nil)
return
}
guard Permissions.localNetwork(using: viewModel.dependencies) == .granted else {
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "permissionsRequired".localized(),
body: .text("permissionsLocalNetworkAccessRequiredCallsIos".localized()),
showCondition: .disabled,
confirmTitle: "sessionSettings".localized(),
onConfirm: { _ in
UIApplication.shared.openSystemSettings()
}
)
)
self.navigationController?.present(confirmationModal, animated: true, completion: nil)
return
}
let threadId: String = self.viewModel.threadData.threadId
guard
Permissions.microphone == .granted,
self.viewModel.threadData.threadVariant == .contact,
viewModel.dependencies[singleton: .callManager].currentCall == nil,
let call: SessionCall = viewModel.dependencies[singleton: .storage]
.read({ [dependencies = viewModel.dependencies] db in
SessionCall(
db,
for: threadId,
uuid: UUID().uuidString.lowercased(),
mode: .offer,
outgoing: true,
using: dependencies
)
})
viewModel.dependencies[singleton: .callManager].currentCall == nil
else { return }
let call: SessionCall = SessionCall(
for: threadId,
contactName: self.viewModel.threadData.displayName,
uuid: UUID().uuidString.lowercased(),
mode: .offer,
using: viewModel.dependencies
)
let callVC = CallVC(for: call, using: viewModel.dependencies)
callVC.conversationVC = self
hideInputAccessoryView()
@ -943,12 +971,14 @@ extension ConversationVC:
),
messageInfo.state == .permissionDeniedMicrophone
else {
let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(caller: cellViewModel.authorName)
let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(
caller: cellViewModel.authorName,
presentingViewController: self,
using: viewModel.dependencies
)
present(callMissedTipsModal, animated: true, completion: nil)
return
}
Permissions.requestMicrophonePermissionIfNeeded(presentingViewController: self, using: viewModel.dependencies)
return
}
@ -1309,7 +1339,7 @@ extension ConversationVC:
}
func handleReplyButtonTapped(for cellViewModel: MessageViewModel) {
reply(cellViewModel)
reply(cellViewModel, completion: nil)
}
func startThread(
@ -1883,7 +1913,7 @@ extension ConversationVC:
}
}
func retry(_ cellViewModel: MessageViewModel) {
func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
guard cellViewModel.id != MessageViewModel.optimisticUpdateId else {
guard
let optimisticMessageId: UUID = cellViewModel.optimisticMessageId,
@ -1895,7 +1925,10 @@ extension ConversationVC:
title: "theError".localized(),
body: .text("shareExtensionDatabaseError".localized()),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text
cancelStyle: .alert_text,
afterClosed: {
completion?()
}
)
)
@ -1905,6 +1938,7 @@ extension ConversationVC:
// Try to send the optimistic message again
sendMessage(optimisticData: optimisticMessageData)
completion?()
return
}
@ -1953,9 +1987,11 @@ extension ConversationVC:
using: dependencies
)
}
completion?()
}
func reply(_ cellViewModel: MessageViewModel) {
func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending(
threadId: self.viewModel.threadData.threadId,
authorId: cellViewModel.authorId,
@ -1976,9 +2012,10 @@ extension ConversationVC:
isOutgoing: (cellViewModel.variant == .standardOutgoing)
)
_ = snInputView.becomeFirstResponder()
completion?()
}
func copy(_ cellViewModel: MessageViewModel) {
func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
switch cellViewModel.cellType {
case .typingIndicator, .dateHeader, .unreadMarker: break
@ -2006,15 +2043,35 @@ extension ConversationVC:
UIPasteboard.general.setData(data, forPasteboardType: type.identifier)
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
self?.viewModel.showToast(
text: "copied".localized(),
backgroundColor: .toast_background,
inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0)
)
}
completion?()
}
func copySessionID(_ cellViewModel: MessageViewModel) {
func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
guard cellViewModel.variant == .standardIncoming else { return }
UIPasteboard.general.string = cellViewModel.authorId
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
self?.viewModel.showToast(
text: "copied".localized(),
backgroundColor: .toast_background,
inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0)
)
}
completion?()
}
func delete(_ cellViewModel: MessageViewModel) {
func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
/// Retrieve the deletion actions for the selected message(s) of there are any
let messagesToDelete: [MessageViewModel] = [cellViewModel]
@ -2098,6 +2155,7 @@ extension ConversationVC:
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
completion?()
}
}
)
@ -2115,7 +2173,7 @@ extension ConversationVC:
}
}
func save(_ cellViewModel: MessageViewModel) {
func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
guard cellViewModel.cellType == .mediaMessage else { return }
let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? [])
@ -2155,7 +2213,15 @@ extension ConversationVC:
)
}
},
completionHandler: { _, _ in }
completionHandler: { _, _ in
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
self?.viewModel.showToast(
text: "saved".localized(),
backgroundColor: .toast_background,
inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0)
)
}
}
)
}
@ -2166,9 +2232,11 @@ extension ConversationVC:
self?.sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)))
}
completion?()
}
func ban(_ cellViewModel: MessageViewModel) {
func ban(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
guard cellViewModel.threadVariant == .community else { return }
let threadId: String = self.viewModel.threadData.threadId
@ -2201,36 +2269,38 @@ extension ConversationVC:
.receive(on: DispatchQueue.main, using: dependencies)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished:
DispatchQueue.main.async { [weak self] in
DispatchQueue.main.async { [weak self] in
switch result {
case .finished:
self?.viewModel.showToast(
text: "banUserBanned".localized(),
backgroundColor: .backgroundSecondary,
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
case .failure:
DispatchQueue.main.async { [weak self] in
case .failure:
self?.viewModel.showToast(
text: "banErrorFailed".localized(),
backgroundColor: .backgroundSecondary,
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
}
completion?()
}
}
)
self?.becomeFirstResponder()
},
afterClosed: { [weak self] in self?.becomeFirstResponder() }
afterClosed: { [weak self] in
completion?()
self?.becomeFirstResponder()
}
)
)
self.present(modal, animated: true)
}
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) {
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
guard cellViewModel.threadVariant == .community else { return }
let threadId: String = self.viewModel.threadData.threadId
@ -2263,30 +2333,31 @@ extension ConversationVC:
.receive(on: DispatchQueue.main, using: dependencies)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished:
DispatchQueue.main.async { [weak self] in
DispatchQueue.main.async { [weak self] in
switch result {
case .finished:
self?.viewModel.showToast(
text: "banUserBanned".localized(),
backgroundColor: .backgroundSecondary,
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
case .failure:
DispatchQueue.main.async { [weak self] in
case .failure:
self?.viewModel.showToast(
text: "banErrorFailed".localized(),
backgroundColor: .backgroundSecondary,
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
)
}
}
completion?()
}
}
)
self?.becomeFirstResponder()
},
afterClosed: { [weak self] in self?.becomeFirstResponder() }
afterClosed: { [weak self] in
self?.becomeFirstResponder()
}
)
)
self.present(modal, animated: true)

@ -227,8 +227,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
icon: .close,
tintColor: .messageBubble_outgoingText,
backgroundColor: .primary,
accessibility: Accessibility(label: "Outdated client banner"),
labelAccessibility: Accessibility(label: "Outdated client banner text"),
labelAccessibility: Accessibility(identifier: "Outdated client banner"),
height: 40,
onTap: { [weak self] in self?.removeOutdatedClientBanner() }
)
@ -246,7 +245,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
icon: .none,
tintColor: .messageBubble_outgoingText,
backgroundColor: .primary,
accessibility: Accessibility(label: "Legacy group banner"),
labelAccessibility: Accessibility(identifier: "Legacy group banner"),
height: nil,
onTap: { [weak self] in self?.openUrl(Features.legacyGroupDepricationUrl) }
)
@ -268,7 +267,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
icon: .none,
tintColor: .black,
backgroundColor: .explicitPrimary(.orange),
accessibility: Accessibility(label: "Expired group banner"),
labelAccessibility: Accessibility(identifier: "Expired group banner"),
height: nil
)
)
@ -290,15 +289,16 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
private lazy var emptyStateLabelContainer: UIView = {
let result: UIView = UIView()
result.addSubview(emptyStateLabel)
emptyStateLabel.pin(.top, to: .top, of: result)
emptyStateLabel.pin(.leading, to: .leading, of: result, withInset: Values.largeSpacing)
emptyStateLabel.pin(.trailing, to: .trailing, of: result, withInset: -Values.largeSpacing)
emptyStateLabel.pin(.bottom, to: .bottom, of: result)
return result
}()
private lazy var emptyStateLabel: UILabel = {
let result: UILabel = UILabel()
result.isAccessibilityElement = true
result.accessibilityIdentifier = "Control message"
result.translatesAutoresizingMaskIntoConstraints = false
result.font = .systemFont(ofSize: Values.verySmallFontSize)
@ -942,7 +942,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
self.viewModel.updateInteractionData(updatedData)
// Update the empty state
self.emptyStateLabel.isHidden = hasMessages
self.emptyStateLabelContainer.isHidden = hasMessages
UIView.performWithoutAnimation {
self.tableView.reloadData()
@ -954,7 +954,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
}
// Update the empty state
self.emptyStateLabel.isHidden = hasMessages
self.emptyStateLabelContainer.isHidden = hasMessages
// Update the ReactionListSheet (if one exists)
if let messageUpdates: [MessageViewModel] = updatedData.first(where: { $0.model == .messages })?.elements {
@ -1360,7 +1360,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
}
else {
let shouldHaveCallButton: Bool = (
SessionCall.isEnabled &&
(threadData?.threadVariant ?? initialVariant) == .contact &&
(threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false &&
(threadData?.threadIsBlocked ?? initialIsBlocked) == false

@ -934,25 +934,23 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold
// MARK: - Functions
public func updateDraft(to draft: String) {
let threadId: String = self.threadId
let currentDraft: String = dependencies[singleton: .storage]
.read { db in
/// Kick off an async process to save the `draft` message to the conversation (don't want to block the UI while doing this,
/// worst case the `draft` just won't be saved)
dependencies[singleton: .storage]
.readPublisher { [threadId] db in
try SessionThread
.select(.messageDraft)
.filter(id: threadId)
.asRequest(of: String.self)
.fetchOne(db)
}
.defaulting(to: "")
// Only write the updated draft to the database if it's changed (avoid unnecessary writes)
guard draft != currentDraft else { return }
dependencies[singleton: .storage].writeAsync { db in
try SessionThread
.filter(id: threadId)
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
}
.filter { existingDraft -> Bool in draft != existingDraft }
.flatMapStorageWritePublisher(using: dependencies) { [threadId] db, _ in
try SessionThread
.filter(id: threadId)
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
}
.sinkUntilComplete()
}
/// This method indicates whether the client should try to mark the thread or it's messages as read (it's an optimisation for fully read

@ -303,22 +303,28 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
// Suggest that the user enable link previews if they haven't already and we haven't
// told them about link previews yet
let text = inputTextView.text!
let areLinkPreviewsEnabled: Bool = dependencies[singleton: .storage, key: .areLinkPreviewsEnabled]
if
!LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty &&
!areLinkPreviewsEnabled &&
!dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion]
{
delegate?.showLinkPreviewSuggestionModal()
dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] = true
return
DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies] in
let areLinkPreviewsEnabled: Bool = dependencies[singleton: .storage, key: .areLinkPreviewsEnabled]
if
!LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty &&
!areLinkPreviewsEnabled &&
!dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion]
{
DispatchQueue.main.async {
self?.delegate?.showLinkPreviewSuggestionModal()
}
dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] = true
return
}
// Check that link previews are enabled
guard areLinkPreviewsEnabled else { return }
// Proceed
DispatchQueue.main.async {
self?.autoGenerateLinkPreview()
}
}
// Check that link previews are enabled
guard areLinkPreviewsEnabled else { return }
// Proceed
autoGenerateLinkPreview()
}
func autoGenerateLinkPreview() {
@ -473,6 +479,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
inputTextView.resignFirstResponder()
}
@discardableResult
override func becomeFirstResponder() -> Bool {
inputTextView.becomeFirstResponder()
}

@ -1,7 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
@ -150,7 +149,7 @@ public class MediaView: UIView {
}
private func configureForAnimatedImage(attachment: Attachment) {
let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
let animatedImageView: AnimatedImageView = AnimatedImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
animatedImageView.contentMode = MediaView.contentMode
@ -183,18 +182,19 @@ public class MediaView: UIView {
return
}
applyMediaBlock(YYImage(contentsOfFile: filePath))
applyMediaBlock(filePath as AnyObject)
},
applyMediaBlock: { media in
applyMediaBlock: { filePath in
Log.assertOnMainThread()
guard let image: YYImage = media as? YYImage else {
Log.error("[MediaView] Media has unexpected type: \(type(of: media))")
guard let filePath: String = filePath as? String else {
Log.error("[MediaView] Media has unexpected type: \(type(of: filePath))")
self?.configure(forError: .invalid)
return
}
// FIXME: Animated images flicker when reloading the cells (even though they are in the cache)
animatedImageView.image = image
animatedImageView.loadAnimatedImage(from: filePath)
},
cacheKey: attachment.id
)

@ -22,13 +22,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
override var contextSnapshotView: UIView? { return snContentView }
// Constraints
private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
internal lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
private lazy var profilePictureViewLeadingConstraint = profilePictureView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
internal lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize)
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
private lazy var contentViewTrailingConstraint1 = snContentView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
internal lazy var contentViewTrailingConstraint1 = snContentView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
private lazy var contentViewTrailingConstraint2 = snContentView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -VisibleMessageCell.gutterSize)
private lazy var contentBottomConstraint = snContentView.bottomAnchor
.constraint(lessThanOrEqualTo: self.bottomAnchor, constant: -1)
@ -470,7 +470,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
documentView = nil
bodyTappableLabel = nil
// Handle the deleted state first (it's much simpler than the others)
/// These variants have no content so do nothing after cleaning up old state
guard
cellViewModel.cellType != .typingIndicator &&
cellViewModel.cellType != .dateHeader &&
cellViewModel.cellType != .unreadMarker
else { return }
/// Handle the deleted state first (it's much simpler than the others)
guard !cellViewModel.variant.isDeletedMessage else {
let inset: CGFloat = 12
let deletedMessageView: DeletedMessageView = DeletedMessageView(
@ -484,125 +491,223 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
return
}
// If it's an incoming media message and the thread isn't trusted then show the placeholder view
if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted {
let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor)
bubbleView.addSubview(mediaPlaceholderView)
mediaPlaceholderView.pin(to: bubbleView)
snContentView.addArrangedSubview(bubbleBackgroundView)
return
}
switch cellViewModel.cellType {
case .typingIndicator, .dateHeader, .unreadMarker: break
/// The `textOnlyMessage` variant has a slightly different behaviour (as it's the only variant which supports link previews)
/// so we handle that case first
// FIXME: We should support rendering link previews alongside the other variants (bigger refactor)
guard cellViewModel.cellType != .textOnlyMessage else {
let inset: CGFloat = 12
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
case .textOnlyMessage:
let inset: CGFloat = 12
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
if let linkPreview: LinkPreview = cellViewModel.linkPreview {
switch linkPreview.variant {
case .standard:
let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth)
linkPreviewView.update(
with: LinkPreview.SentState(
linkPreview: linkPreview,
imageAttachment: cellViewModel.linkPreviewAttachment,
using: dependencies
),
isOutgoing: cellViewModel.variant.isOutgoing,
delegate: self,
cellViewModel: cellViewModel,
bodyLabelTextColor: bodyLabelTextColor,
lastSearchText: lastSearchText,
if let linkPreview: LinkPreview = cellViewModel.linkPreview {
switch linkPreview.variant {
case .standard:
let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth)
linkPreviewView.update(
with: LinkPreview.SentState(
linkPreview: linkPreview,
imageAttachment: cellViewModel.linkPreviewAttachment,
using: dependencies
)
self.linkPreviewView = linkPreviewView
bubbleView.addSubview(linkPreviewView)
linkPreviewView.pin(to: bubbleView, withInset: 0)
snContentView.addArrangedSubview(bubbleBackgroundView)
self.bodyTappableLabel = linkPreviewView.bodyTappableLabel
case .openGroupInvitation:
let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView(
name: (linkPreview.title ?? ""),
url: linkPreview.url,
textColor: bodyLabelTextColor,
isOutgoing: cellViewModel.variant.isOutgoing
)
openGroupInvitationView.isAccessibilityElement = true
openGroupInvitationView.accessibilityIdentifier = "Community invitation"
openGroupInvitationView.accessibilityLabel = cellViewModel.linkPreview?.title
bubbleView.addSubview(openGroupInvitationView)
bubbleView.pin(to: openGroupInvitationView)
snContentView.addArrangedSubview(bubbleBackgroundView)
}
}
else {
// Stack view
let stackView = UIStackView(arrangedSubviews: [])
stackView.axis = .vertical
stackView.spacing = 2
// Quote view
if let quote: Quote = cellViewModel.quote {
let hInset: CGFloat = 2
let quoteView: QuoteView = QuoteView(
for: .regular,
authorId: quote.authorId,
quotedText: quote.body,
threadVariant: cellViewModel.threadVariant,
currentUserSessionId: cellViewModel.currentUserSessionId,
currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId,
currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId,
direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming),
attachment: cellViewModel.quoteAttachment,
),
isOutgoing: cellViewModel.variant.isOutgoing,
delegate: self,
cellViewModel: cellViewModel,
bodyLabelTextColor: bodyLabelTextColor,
lastSearchText: lastSearchText,
using: dependencies
)
self.quoteView = quoteView
let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset))
stackView.addArrangedSubview(quoteViewContainer)
}
// Body text view
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self,
self.linkPreviewView = linkPreviewView
bubbleView.addSubview(linkPreviewView)
linkPreviewView.pin(to: bubbleView, withInset: 0)
snContentView.addArrangedSubview(bubbleBackgroundView)
self.bodyTappableLabel = linkPreviewView.bodyTappableLabel
case .openGroupInvitation:
let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView(
name: (linkPreview.title ?? ""),
url: linkPreview.url,
textColor: bodyLabelTextColor,
isOutgoing: cellViewModel.variant.isOutgoing
)
openGroupInvitationView.isAccessibilityElement = true
openGroupInvitationView.accessibilityIdentifier = "Community invitation"
openGroupInvitationView.accessibilityLabel = cellViewModel.linkPreview?.title
bubbleView.addSubview(openGroupInvitationView)
bubbleView.pin(to: openGroupInvitationView)
snContentView.addArrangedSubview(bubbleBackgroundView)
}
}
else {
// Stack view
let stackView = UIStackView(arrangedSubviews: [])
stackView.axis = .vertical
stackView.spacing = 2
// Quote view
if let quote: Quote = cellViewModel.quote {
let hInset: CGFloat = 2
let quoteView: QuoteView = QuoteView(
for: .regular,
authorId: quote.authorId,
quotedText: quote.body,
threadVariant: cellViewModel.threadVariant,
currentUserSessionId: cellViewModel.currentUserSessionId,
currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId,
currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId,
direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming),
attachment: cellViewModel.quoteAttachment,
using: dependencies
)
self.bodyTappableLabel = bodyTappableLabel
stackView.addArrangedSubview(bodyTappableLabel)
// Constraints
bubbleView.addSubview(stackView)
stackView.pin(to: bubbleView, withInset: inset)
stackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true
snContentView.addArrangedSubview(bubbleBackgroundView)
self.quoteView = quoteView
let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset))
stackView.addArrangedSubview(quoteViewContainer)
}
case .mediaMessage:
// Body text view
if let body: String = cellViewModel.body, !body.isEmpty {
let inset: CGFloat = 12
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self,
using: dependencies
)
self.bodyTappableLabel = bodyTappableLabel
bubbleView.addSubview(bodyTappableLabel)
bodyTappableLabel.pin(to: bubbleView, withInset: inset)
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self,
using: dependencies
)
self.bodyTappableLabel = bodyTappableLabel
stackView.addArrangedSubview(bodyTappableLabel)
// Constraints
bubbleView.addSubview(stackView)
stackView.pin(to: bubbleView, withInset: inset)
stackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true
snContentView.addArrangedSubview(bubbleBackgroundView)
}
return
}
func addViewWrappingInBubbleIfNeeded(_ targetView: UIView) {
switch snContentView.arrangedSubviews.count {
case 0:
bubbleView.addSubview(targetView)
targetView.pin(to: bubbleView)
snContentView.addArrangedSubview(bubbleBackgroundView)
}
default:
/// Since we already have content we need to wrap the `targetView` in it's own
/// `bubbleView` (as it's likely the existing content is quote content)
let extraBubbleView: UIView = UIView()
extraBubbleView.clipsToBounds = true
extraBubbleView.themeBackgroundColor = (cellViewModel.variant.isIncoming ?
.messageBubble_incomingBackground :
.messageBubble_outgoingBackground
)
extraBubbleView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius
extraBubbleView.layer.maskedCorners = getCornerMask(from: .allCorners)
extraBubbleView.set(.width, greaterThanOrEqualTo: VisibleMessageCell.largeCornerRadius * 2)
extraBubbleView.addSubview(targetView)
targetView.pin(to: extraBubbleView)
snContentView.addArrangedSubview(extraBubbleView)
}
}
/// Add any quote & body if present
let inset: CGFloat = 12
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
switch (cellViewModel.quote, cellViewModel.body) {
/// Both quote and body
case (.some(let quote), .some(let body)) where !body.isEmpty:
// Stack view
let stackView = UIStackView(arrangedSubviews: [])
stackView.axis = .vertical
stackView.spacing = 2
// Quote view
let hInset: CGFloat = 2
let quoteView: QuoteView = QuoteView(
for: .regular,
authorId: quote.authorId,
quotedText: quote.body,
threadVariant: cellViewModel.threadVariant,
currentUserSessionId: cellViewModel.currentUserSessionId,
currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId,
currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId,
direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming),
attachment: cellViewModel.quoteAttachment,
using: dependencies
)
self.quoteView = quoteView
let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset))
stackView.addArrangedSubview(quoteViewContainer)
// Body
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self,
using: dependencies
)
self.bodyTappableLabel = bodyTappableLabel
stackView.addArrangedSubview(bodyTappableLabel)
// Constraints
bubbleView.addSubview(stackView)
stackView.pin(to: bubbleView, withInset: inset)
stackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true
snContentView.addArrangedSubview(bubbleBackgroundView)
/// Just body
case (_, .some(let body)) where !body.isEmpty:
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self,
using: dependencies
)
self.bodyTappableLabel = bodyTappableLabel
bubbleView.addSubview(bodyTappableLabel)
bodyTappableLabel.pin(to: bubbleView, withInset: inset)
snContentView.addArrangedSubview(bubbleBackgroundView)
/// Just quote
case (.some(let quote), _):
let quoteView: QuoteView = QuoteView(
for: .regular,
authorId: quote.authorId,
quotedText: quote.body,
threadVariant: cellViewModel.threadVariant,
currentUserSessionId: cellViewModel.currentUserSessionId,
currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId,
currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId,
direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming),
attachment: cellViewModel.quoteAttachment,
using: dependencies
)
self.quoteView = quoteView
bubbleView.addSubview(quoteView)
quoteView.pin(to: bubbleView, withInset: inset)
snContentView.addArrangedSubview(bubbleBackgroundView)
/// Neither quote or body
default: break
}
/// If it's an incoming media message and the thread isn't trusted then show the placeholder view
if cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted {
let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor)
addViewWrappingInBubbleIfNeeded(mediaPlaceholderView)
return
}
switch cellViewModel.cellType {
case .typingIndicator, .dateHeader, .unreadMarker, .textOnlyMessage: break
case .mediaMessage:
// Album view
let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: cellViewModel)
let albumView = MediaAlbumView(
@ -637,52 +742,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
playbackRate: (playbackInfo?.playbackRate ?? 1),
oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1)
)
bubbleView.addSubview(voiceMessageView)
voiceMessageView.pin(to: bubbleView)
snContentView.addArrangedSubview(bubbleBackgroundView)
self.voiceMessageView = voiceMessageView
addViewWrappingInBubbleIfNeeded(voiceMessageView)
case .audio, .genericAttachment:
guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() }
let inset: CGFloat = 12
let maxWidth = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
// Stack view
let stackView = UIStackView(arrangedSubviews: [])
stackView.axis = .vertical
stackView.spacing = Values.smallSpacing
// Document view
let documentView = DocumentView(attachment: attachment, textColor: bodyLabelTextColor)
self.documentView = documentView
stackView.addArrangedSubview(documentView)
// Body text view
if let body: String = cellViewModel.body, !body.isEmpty { // delegate should always be set at this point
let bodyContainerView: UIView = UIView()
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self,
using: dependencies
)
self.bodyTappableLabel = bodyTappableLabel
bodyContainerView.addSubview(bodyTappableLabel)
bodyTappableLabel.pin(.top, to: .top, of: bodyContainerView)
bodyTappableLabel.pin(.leading, to: .leading, of: bodyContainerView, withInset: 12)
bodyTappableLabel.pin(.trailing, to: .trailing, of: bodyContainerView, withInset: -12)
bodyTappableLabel.pin(.bottom, to: .bottom, of: bodyContainerView, withInset: -12)
stackView.addArrangedSubview(bodyContainerView)
}
bubbleView.addSubview(stackView)
stackView.pin(to: bubbleView)
snContentView.addArrangedSubview(bubbleBackgroundView)
addViewWrappingInBubbleIfNeeded(documentView)
}
}

@ -1,13 +1,12 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import SessionUIKit
/// Shown when the user taps a profile picture in the conversation settings.
final class ProfilePictureVC: BaseVC {
private let image: UIImage?
private let animatedImage: YYImage?
private let animatedImageData: Data?
private let snTitle: String
private var imageSize: CGFloat { (UIScreen.main.bounds.width - (2 * Values.largeSpacing)) }
@ -21,7 +20,7 @@ final class ProfilePictureVC: BaseVC {
result.layer.cornerRadius = (imageSize / 2)
result.isHidden = (
image != nil ||
animatedImage != nil
animatedImageData != nil
)
result.set(.width, to: imageSize)
result.set(.height, to: imageSize)
@ -41,12 +40,13 @@ final class ProfilePictureVC: BaseVC {
return result
}()
private lazy var animatedImageView: YYAnimatedImageView = {
let result: YYAnimatedImageView = YYAnimatedImageView(image: animatedImage)
private lazy var animatedImageView: AnimatedImageView = {
let result: AnimatedImageView = AnimatedImageView()
result.loadAnimatedImage(from: animatedImageData)
result.clipsToBounds = true
result.contentMode = .scaleAspectFill
result.layer.cornerRadius = (imageSize / 2)
result.isHidden = (animatedImage == nil)
result.isHidden = (animatedImageData == nil)
result.set(.width, to: imageSize)
result.set(.height, to: imageSize)
@ -55,9 +55,9 @@ final class ProfilePictureVC: BaseVC {
// MARK: - Initialization
init(image: UIImage?, animatedImage: YYImage?, title: String) {
init(image: UIImage?, animatedImageData: Data?, title: String) {
self.image = image
self.animatedImage = animatedImage
self.animatedImageData = animatedImageData
self.snTitle = title
super.init(nibName: nil, bundle: nil)

@ -4,7 +4,6 @@ import Foundation
import Combine
import Lucide
import GRDB
import YYImage
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
@ -661,7 +660,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob
threadViewModel.threadIsBlocked == true,
oldValue: (previous?.threadViewModel?.threadIsBlocked == true),
accessibility: Accessibility(
identifier: "Block This User - Switch"
identifier: "Block - Switch"
)
),
accessibility: Accessibility(
@ -669,29 +668,20 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob
label: "Block"
),
confirmationInfo: ConfirmationModal.Info(
title: {
guard threadViewModel.threadIsBlocked == true else {
return String(
format: "block".localized(),
threadViewModel.displayName
)
}
return String(
format: "blockUnblock".localized(),
threadViewModel.displayName
)
}(),
title: (threadViewModel.threadIsBlocked == true ?
"blockUnblock".localized() :
"block".localized()
),
body: (threadViewModel.threadIsBlocked == true ?
.attributedText(
"blockUnblockName"
.put(key: "name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
) :
.attributedText(
"blockDescription"
.put(key: "name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
),
confirmTitle: (threadViewModel.threadIsBlocked == true ?
@ -823,9 +813,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob
nil :
UIImage(data: displayPictureData)
),
animatedImage: (format != .gif && format != .webp ?
animatedImageData: (format != .gif && format != .webp ?
nil :
YYImage(data: displayPictureData)
displayPictureData
),
title: threadViewModel.displayName
)
@ -1130,7 +1120,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob
placeholder: "nicknameEnter".localized(),
initialValue: current,
accessibility: Accessibility(
identifier: "Username"
identifier: "Username input"
)
),
onChange: { [weak self] updatedName in self?.updatedName = updatedName }

@ -156,7 +156,6 @@ final class ConversationTitleView: UIView {
// Contact threads also have the call button to compensate for
let shouldShowCallButton: Bool = (
SessionCall.isEnabled &&
!isNoteToSelf &&
threadVariant == .contact
)

@ -468,6 +468,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
isEnabled: (authorId == self.messageViewModel.currentUserSessionId)
),
tableSize: tableView.bounds.size,
using: dependencies
)

@ -20,7 +20,7 @@ private extension Log.Category {
class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UITableViewDelegate, UITableViewDataSource {
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
fileprivate struct SearchResultData {
fileprivate struct SearchResultData: Equatable {
var state: SearchResultsState
var data: [SectionModel]
}
@ -50,70 +50,31 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI
// MARK: - Variables
private let dependencies: Dependencies
private lazy var defaultSearchResults: SearchResultData = {
let nonalphabeticNameTitle: String = "#" // stringlint:ignore
let contacts: [SessionThreadViewModel] = dependencies[singleton: .storage].read { [dependencies] db -> [SessionThreadViewModel]? in
private var defaultSearchResults: SearchResultData = SearchResultData(state: .none, data: []) {
didSet {
guard searchText.isEmpty else { return }
/// If we have no search term then the contact list should be showing, so update the results and reload the table
self.searchResultSet = defaultSearchResults
switch Thread.isMainThread {
case true: self.tableView.reloadData()
case false: DispatchQueue.main.async { self.tableView.reloadData() }
}
}
}
private lazy var defaultSearchResultsObservation = ValueObservation
.trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in
try SessionThreadViewModel
.defaultContactsQuery(using: dependencies)
.fetchAll(db)
}
.defaulting(to: [])
.sorted {
$0.displayName.lowercased() < $1.displayName.lowercased()
}
var groupedContacts: [String: SectionModel] = [:]
contacts.forEach { contactViewModel in
guard !contactViewModel.threadIsNoteToSelf else {
groupedContacts[""] = SectionModel(
model: .groupedContacts(title: ""),
elements: [contactViewModel]
)
return
}
let displayName = NSMutableString(string: contactViewModel.displayName)
CFStringTransform(displayName, nil, kCFStringTransformToLatin, false)
CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false)
let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "")
let section: String = initialCharacter.capitalized.isSingleAlphabet ?
initialCharacter.capitalized :
nonalphabeticNameTitle
if groupedContacts[section] == nil {
groupedContacts[section] = SectionModel(
model: .groupedContacts(title: section),
elements: []
)
}
groupedContacts[section]?.elements.append(contactViewModel)
}
return SearchResultData(
state: .defaultContacts,
data: groupedContacts.values.sorted { sectionModel0, sectionModel1 in
let title0: String = {
switch sectionModel0.model {
case .groupedContacts(let title): return title
default: return ""
}
}()
let title1: String = {
switch sectionModel1.model {
case .groupedContacts(let title): return title
default: return ""
}
}()
if ![title0, title1].contains(nonalphabeticNameTitle) {
return title0 < title1
}
return title1 == nonalphabeticNameTitle
}
)
}()
.map { GlobalSearchViewController.processDefaultSearchResults($0) }
.removeDuplicates()
.handleEvents(didFail: { Log.error(.cat, "Observation failed with error: \($0)") })
private var defaultDataChangeObservable: DatabaseCancellable? {
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
}
@ThreadSafeObject private var currentSearchCancellable: AnyCancellable? = nil
private lazy var searchResultSet: SearchResultData = defaultSearchResults
@ -186,6 +147,18 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI
navigationItem.hidesBackButton = true
setupNavigationBar()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
defaultDataChangeObservable = dependencies[singleton: .storage].start(
defaultSearchResultsObservation,
onError: { _ in },
onChange: { [weak self] updatedDefaultResults in
self?.defaultSearchResults = updatedDefaultResults
}
)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
@ -195,6 +168,8 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.defaultDataChangeObservable = nil
UIView.performWithoutAnimation {
searchBar.resignFirstResponder()
}
@ -240,6 +215,64 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI
}
// MARK: - Update Search Results
private static func processDefaultSearchResults(_ contacts: [SessionThreadViewModel]) -> SearchResultData {
let nonalphabeticNameTitle: String = "#" // stringlint:ignore
return SearchResultData(
state: .defaultContacts,
data: contacts
.sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() }
.reduce(into: [String: SectionModel]()) { result, next in
guard !next.threadIsNoteToSelf else {
result[""] = SectionModel(
model: .groupedContacts(title: ""),
elements: [next]
)
return
}
let displayName = NSMutableString(string: next.displayName)
CFStringTransform(displayName, nil, kCFStringTransformToLatin, false)
CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false)
let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "")
let section: String = (initialCharacter.capitalized.isSingleAlphabet ?
initialCharacter.capitalized :
nonalphabeticNameTitle
)
if result[section] == nil {
result[section] = SectionModel(
model: .groupedContacts(title: section),
elements: []
)
}
result[section]?.elements.append(next)
}
.values
.sorted { sectionModel0, sectionModel1 in
let title0: String = {
switch sectionModel0.model {
case .groupedContacts(let title): return title
default: return ""
}
}()
let title1: String = {
switch sectionModel1.model {
case .groupedContacts(let title): return title
default: return ""
}
}()
if ![title0, title1].contains(nonalphabeticNameTitle) {
return title0 < title1
}
return title1 == nonalphabeticNameTitle
}
)
}
private func refreshSearchResults() {
refreshTimer?.invalidate()
@ -381,6 +414,32 @@ extension GlobalSearchViewController {
)
}
}
public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: SectionModel = self.searchResultSet.data[indexPath.section]
switch section.model {
case .contactsAndGroups, .messages: return nil
case .groupedContacts:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
/// No actions for `Note to Self`
guard !threadViewModel.threadIsNoteToSelf else { return nil }
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[.block, .deleteContact],
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self,
navigatableStateHolder: nil,
using: dependencies
)
)
}
}
private func show(
threadId: String,

@ -1,9 +1,9 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UIKit
import Combine
import UniformTypeIdentifiers
import YYImage
import SessionUIKit
import SessionSnodeKit
import SignalUtilitiesKit
import SessionUtilitiesKit
@ -37,7 +37,7 @@ class GifPickerCell: UICollectionViewCell {
var stillAsset: ProxiedContentAsset?
var animatedAssetRequest: ProxiedContentAssetRequest?
var animatedAsset: ProxiedContentAsset?
var imageView: YYAnimatedImageView?
var imageView: AnimatedImageView?
var activityIndicator: UIActivityIndicatorView?
var isCellSelected: Bool = false {
@ -206,13 +206,8 @@ class GifPickerCell: UICollectionViewCell {
clearViewState()
return
}
guard let image = YYImage(contentsOfFile: asset.filePath) else {
Log.error(.giphy, "Cell could not load asset.")
clearViewState()
return
}
if imageView == nil {
let imageView = YYAnimatedImageView()
let imageView = AnimatedImageView()
self.imageView = imageView
self.contentView.addSubview(imageView)
imageView.pin(to: contentView)
@ -222,7 +217,7 @@ class GifPickerCell: UICollectionViewCell {
clearViewState()
return
}
imageView.image = image
imageView.loadAnimatedImage(from: URL(fileURLWithPath: asset.filePath))
imageView.accessibilityIdentifier = "gif cell"
self.themeBackgroundColor = nil

@ -3,7 +3,6 @@
import UIKit
import AVKit
import AVFoundation
import YYImage
import SessionUIKit
import SignalUtilitiesKit
import SessionMessagingKit
@ -132,8 +131,8 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate {
}
public func parentDidAppear() {
if mediaView is YYAnimatedImageView {
(mediaView as? YYAnimatedImageView)?.startAnimating()
if mediaView is AnimatedImageView {
(mediaView as? AnimatedImageView)?.startAnimating()
}
if self.galleryItem.attachment.isVideo {
@ -160,7 +159,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate {
let maybeImageSize: CGSize? = {
switch self.mediaView {
case let imageView as UIImageView: return (imageView.image?.size ?? .zero)
case let imageView as YYAnimatedImageView: return (imageView.image?.size ?? .zero)
default: return nil
}
}()
@ -204,9 +202,9 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate {
if self.galleryItem.attachment.isAnimated {
if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath(using: dependencies) {
let animatedView: YYAnimatedImageView = YYAnimatedImageView()
animatedView.autoPlayAnimatedImage = false
animatedView.image = YYImage(contentsOfFile: originalFilePath)
let animatedView: AnimatedImageView = AnimatedImageView()
animatedView.loadAnimatedImage(from: originalFilePath)
animatedView.startAnimating()
self.mediaView = animatedView
}
else {

@ -10,6 +10,7 @@ struct MessageInfoScreen: View {
@EnvironmentObject var host: HostWrapper
@State var index = 1
@State var feedbackMessage: String? = nil
static private let cornerRadius: CGFloat = 17
@ -27,114 +28,154 @@ struct MessageInfoScreen: View {
alignment: .leading,
spacing: 10
) {
// Message bubble snapshot
MessageBubble(
messageViewModel: messageViewModel,
dependencies: dependencies
)
.background(
RoundedRectangle(cornerRadius: Self.cornerRadius)
.fill(
themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ?
.messageBubble_incomingBackground :
.messageBubble_outgoingBackground)
)
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Values.smallSpacing)
.padding(.bottom, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
if isMessageFailed {
let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo(
variant: messageViewModel.variant,
hasBeenReadByRecipient: messageViewModel.hasBeenReadByRecipient,
hasAttachments: (messageViewModel.attachments?.isEmpty == false)
VStack(
alignment: .leading,
spacing: 0
) {
// Message bubble snapshot
MessageBubble(
messageViewModel: messageViewModel,
attachmentOnly: false,
dependencies: dependencies
)
.clipShape(
RoundedRectangle(cornerRadius: Self.cornerRadius)
)
.background(
RoundedRectangle(cornerRadius: Self.cornerRadius)
.fill(
themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ?
.messageBubble_incomingBackground :
.messageBubble_outgoingBackground)
)
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Values.smallSpacing)
.padding(.bottom, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
HStack(spacing: 6) {
if let image: UIImage = image?.withRenderingMode(.alwaysTemplate) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.foregroundColor(themeColor: tintColor)
.frame(width: 13, height: 12)
}
if isMessageFailed {
let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo(
variant: messageViewModel.variant,
hasBeenReadByRecipient: messageViewModel.hasBeenReadByRecipient,
hasAttachments: (messageViewModel.attachments?.isEmpty == false)
)
if let statusText: String = statusText {
Text(statusText)
.font(.system(size: Values.verySmallFontSize))
.foregroundColor(themeColor: tintColor)
HStack(spacing: 6) {
if let image: UIImage = image?.withRenderingMode(.alwaysTemplate) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.foregroundColor(themeColor: tintColor)
.frame(width: 13, height: 12)
}
if let statusText: String = statusText {
Text(statusText)
.font(.system(size: Values.verySmallFontSize))
.foregroundColor(themeColor: tintColor)
}
}
.padding(.top, -Values.smallSpacing)
.padding(.bottom, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
}
.padding(.top, -Values.smallSpacing)
.padding(.bottom, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
}
if let attachments = messageViewModel.attachments,
messageViewModel.cellType == .mediaMessage
{
let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count]
ZStack(alignment: .bottomTrailing) {
if attachments.count > 1 {
// Attachment carousel view
SessionCarouselView_SwiftUI(
index: $index,
isOutgoing: (messageViewModel.variant == .standardOutgoing),
contentInfos: attachments,
using: dependencies
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
} else {
MediaView_SwiftUI(
attachment: attachments[0],
isOutgoing: (messageViewModel.variant == .standardOutgoing),
shouldSupressControls: true,
cornerRadius: 0,
using: dependencies
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding(.horizontal, Values.largeSpacing)
}
if [ .downloaded, .uploaded ].contains(attachment.state) {
Button {
self.showMediaFullScreen(attachment: attachment)
} label: {
ZStack {
Circle()
.foregroundColor(.init(white: 0, opacity: 0.4))
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 13))
.foregroundColor(.white)
if let attachments = messageViewModel.attachments {
switch messageViewModel.cellType {
case .mediaMessage:
let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count]
ZStack(alignment: .bottomTrailing) {
if attachments.count > 1 {
// Attachment carousel view
SessionCarouselView_SwiftUI(
index: $index,
isOutgoing: (messageViewModel.variant == .standardOutgoing),
contentInfos: attachments,
using: dependencies
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
} else {
MediaView_SwiftUI(
attachment: attachments[0],
isOutgoing: (messageViewModel.variant == .standardOutgoing),
shouldSupressControls: true,
cornerRadius: 0,
using: dependencies
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding(.horizontal, Values.largeSpacing)
}
if [ .downloaded, .uploaded ].contains(attachment.state) {
Button {
self.showMediaFullScreen(attachment: attachment)
} label: {
ZStack {
Circle()
.foregroundColor(.init(white: 0, opacity: 0.4))
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 13))
.foregroundColor(.white)
}
.frame(width: 26, height: 26)
}
.padding(.bottom, Values.smallSpacing)
.padding(.trailing, 38)
}
}
.frame(width: 26, height: 26)
}
.padding(.bottom, Values.smallSpacing)
.padding(.trailing, 38)
.padding(.vertical, Values.verySmallSpacing)
default:
MessageBubble(
messageViewModel: messageViewModel,
attachmentOnly: true,
dependencies: dependencies
)
.clipShape(
RoundedRectangle(cornerRadius: Self.cornerRadius)
)
.background(
RoundedRectangle(cornerRadius: Self.cornerRadius)
.fill(
themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ?
.messageBubble_incomingBackground :
.messageBubble_outgoingBackground)
)
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
}
}
.padding(.vertical, Values.verySmallSpacing)
}
// Attachment Info
if let attachments = messageViewModel.attachments {
let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count]
// Attachment Info
ZStack {
VStack(
alignment: .leading,
@ -309,8 +350,17 @@ struct MessageInfoScreen: View {
let tintColor: ThemeValue = actions[index].themeColor
Button(
action: {
actions[index].work()
dismiss()
actions[index].work() {
switch (actions[index].shouldDismissInfoScreen, actions[index].feedback) {
case (false, _): break
case (true, .some):
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
dismiss()
})
default: dismiss()
}
}
feedbackMessage = actions[index].feedback
},
label: {
HStack(spacing: Values.largeSpacing) {
@ -353,6 +403,7 @@ struct MessageInfoScreen: View {
}
}
.backgroundColor(themeColor: .backgroundPrimary)
.toastView(message: $feedbackMessage)
}
private func showMediaFullScreen(attachment: Attachment) {
@ -381,6 +432,7 @@ struct MessageBubble: View {
static private let inset: CGFloat = 12
let messageViewModel: MessageViewModel
let attachmentOnly: Bool
let dependencies: Dependencies
var bodyLabelTextColor: ThemeValue {
@ -391,16 +443,16 @@ struct MessageBubble: View {
var body: some View {
ZStack {
switch messageViewModel.cellType {
case .textOnlyMessage:
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * Self.inset)
VStack(
alignment: .leading,
spacing: 0
) {
if let linkPreview: LinkPreview = messageViewModel.linkPreview {
switch linkPreview.variant {
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * Self.inset)
VStack(
alignment: .leading,
spacing: 0
) {
if !attachmentOnly {
// FIXME: We should support rendering link previews alongside quotes (bigger refactor)
if let linkPreview: LinkPreview = messageViewModel.linkPreview {
switch linkPreview.variant {
case .standard:
LinkPreviewView_SwiftUI(
state: LinkPreview.SentState(
@ -421,44 +473,34 @@ struct MessageBubble: View {
url: linkPreview.url,
textColor: bodyLabelTextColor,
isOutgoing: (messageViewModel.variant == .standardOutgoing))
}
}
else {
if let quote = messageViewModel.quote {
QuoteView_SwiftUI(
info: .init(
mode: .regular,
authorId: quote.authorId,
quotedText: quote.body,
threadVariant: messageViewModel.threadVariant,
currentUserSessionId: messageViewModel.currentUserSessionId,
currentUserBlinded15SessionId: messageViewModel.currentUserBlinded15SessionId,
currentUserBlinded25SessionId: messageViewModel.currentUserBlinded25SessionId,
direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming),
attachment: messageViewModel.quoteAttachment
),
using: dependencies
)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Self.inset)
.padding(.horizontal, Self.inset)
.padding(.bottom, -Values.smallSpacing)
}
}
if let bodyText: NSAttributedString = VisibleMessageCell.getBodyAttributedText(
for: messageViewModel,
theme: ThemeManager.currentTheme,
primaryColor: ThemeManager.primaryColor,
textColor: bodyLabelTextColor,
searchText: nil,
using: dependencies
) {
AttributedText(bodyText)
.padding(.all, Self.inset)
}
else {
if let quote = messageViewModel.quote {
QuoteView_SwiftUI(
info: .init(
mode: .regular,
authorId: quote.authorId,
quotedText: quote.body,
threadVariant: messageViewModel.threadVariant,
currentUserSessionId: messageViewModel.currentUserSessionId,
currentUserBlinded15SessionId: messageViewModel.currentUserBlinded15SessionId,
currentUserBlinded25SessionId: messageViewModel.currentUserBlinded25SessionId,
direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming),
attachment: messageViewModel.quoteAttachment
),
using: dependencies
)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Self.inset)
.padding(.horizontal, Self.inset)
.padding(.bottom, (messageViewModel.body?.isEmpty == false ?
-Values.smallSpacing :
Self.inset
))
}
}
case .mediaMessage:
if let bodyText: NSAttributedString = VisibleMessageCell.getBodyAttributedText(
for: messageViewModel,
theme: ThemeManager.currentTheme,
@ -470,51 +512,30 @@ struct MessageBubble: View {
AttributedText(bodyText)
.padding(.all, Self.inset)
}
case .voiceMessage:
if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){
// TODO: Playback Info and check if playing function is needed
VoiceMessageView_SwiftUI(attachment: attachment)
}
case .audio, .genericAttachment:
if let attachment: Attachment = messageViewModel.attachments?.first {
VStack(
alignment: .leading,
spacing: Values.smallSpacing
) {
DocumentView_SwiftUI(
maxWidth: $maxWidth,
attachment: attachment,
textColor: bodyLabelTextColor
)
.modifier(MaxWidthEqualizer.notify)
.frame(
width: maxWidth,
alignment: .leading
)
if let bodyText: NSAttributedString = VisibleMessageCell.getBodyAttributedText(
for: messageViewModel,
theme: ThemeManager.currentTheme,
primaryColor: ThemeManager.primaryColor,
textColor: bodyLabelTextColor,
searchText: nil,
using: dependencies
) {
ZStack{
AttributedText(bodyText)
.padding(.horizontal, Self.inset)
.padding(.bottom, Self.inset)
}
}
else {
switch messageViewModel.cellType {
case .voiceMessage:
if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){
// TODO: Playback Info and check if playing function is needed
VoiceMessageView_SwiftUI(attachment: attachment)
}
case .audio, .genericAttachment:
if let attachment: Attachment = messageViewModel.attachments?.first {
DocumentView_SwiftUI(
maxWidth: $maxWidth,
attachment: attachment,
textColor: bodyLabelTextColor
)
.modifier(MaxWidthEqualizer.notify)
.frame(
width: maxWidth,
alignment: .leading
)
}
}
.modifier(MaxWidthEqualizer(width: $maxWidth))
default: EmptyView()
}
default: EmptyView()
}
}
}
}

@ -147,7 +147,8 @@ class SendMediaNavigationController: UINavigationController {
}
private func didTapCameraModeButton() {
Permissions.requestCameraPermissionIfNeeded(using: dependencies) { [weak self] in
Permissions.requestCameraPermissionIfNeeded(using: dependencies) { [weak self] granted in
guard granted else { return }
DispatchQueue.main.async {
self?.fadeTo(viewControllers: ((self?.captureViewController).map { [$0] } ?? []))
}

@ -133,6 +133,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
)
/// Adding this to prevent new users being asked for local network permission in the wrong order in the permission chain.
/// We need to check the local nework permission status every time the app is activated to refresh the UI in Settings screen.
/// And after granting or denying a system permission request will trigger the local nework permission status check in applicationDidBecomeActive(:)
/// The only way we can check the status of local network permission will trigger the system prompt to ask for the permission.
/// So we need this to keep it the correct order of the permission chain.
/// For users who already enabled the calls permission and made calls, the local network permission should already be asked for.
/// It won't affect anything.
dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] = dependencies[singleton: .storage, key: .areCallsEnabled]
/// Now that the theme settings have been applied we can complete the migrations
self?.completePostMigrationSetup(calledFrom: .finishLaunching)
},
@ -290,6 +299,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// On every activation, clear old temp directories.
dependencies[singleton: .fileManager].clearOldTemporaryDirectories()
if dependencies[singleton: .storage, key: .areCallsEnabled] && dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] {
Permissions.checkLocalNetworkPermission(using: dependencies)
}
}
func applicationWillResignActive(_ application: UIApplication) {
@ -322,9 +335,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
///
/// Additionally we want to ensure that our timeout timer has enough time to run so make sure we have at least `5 seconds`
/// of background execution (if we don't then the process could incorrectly run longer than it should)
let remainingTime: TimeInterval = application.backgroundTimeRemaining
guard
application.backgroundTimeRemaining < TimeInterval.greatestFiniteMagnitude &&
application.backgroundTimeRemaining > 5
remainingTime != TimeInterval.nan &&
remainingTime < TimeInterval.greatestFiniteMagnitude &&
remainingTime > 5
else { return completionHandler(.failed) }
Log.appResumedExecution()
@ -342,7 +358,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
///
/// **Note:** We **MUST** capture both `poller` and `cancellable` strongly in the event handler to ensure neither
/// go out of scope until we want them to (we essentually want a retain cycle in this case)
let durationRemainingMs: Int = max(1, Int((application.backgroundTimeRemaining - 5) * 1000))
let durationRemainingMs: Int = max(1, Int((remainingTime - 5) * 1000))
let timer: DispatchSourceTimer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + .milliseconds(durationRemainingMs))
timer.setEventHandler { [poller, dependencies] in
@ -351,7 +367,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
Log.info(.backgroundPoller, "Background poll failed due to manual timeout.")
cancellable?.cancel()
if dependencies[singleton: .appContext].isInBackground {
if dependencies[singleton: .appContext].isInBackground && !self.hasCallOngoing() {
dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() }
dependencies[singleton: .storage].suspendDatabaseAccess()
Log.flush()
@ -396,7 +412,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
// If we are still running in the background then suspend the network & database
if dependencies[singleton: .appContext].isInBackground {
if dependencies[singleton: .appContext].isInBackground && !self.hasCallOngoing() {
dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() }
dependencies[singleton: .storage].suspendDatabaseAccess()
Log.flush()
@ -870,7 +886,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
guard let presentingVC = dependencies[singleton: .appContext].frontMostViewController else { preconditionFailure() }
let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(
caller: Profile.displayName(id: callerId, using: dependencies)
caller: Profile.displayName(id: callerId, using: dependencies),
presentingViewController: presentingVC,
using: dependencies
)
presentingVC.present(callMissedTipsModal, animated: true, completion: nil)

@ -1,125 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-40.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-60.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-29.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-58.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-87.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-80.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-120.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-121.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-180.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-20.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-41.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-30.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-59.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-42.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-81.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-76.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-152.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-167.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-1024.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"pre-rendered" : true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Calculator-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Calculator-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Calculator-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Meeting-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Meeting-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Meeting-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-News-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-News-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-News-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Notes-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Notes-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Notes-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Stocks-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Stocks-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Stocks-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Weather-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Weather-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Weather-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -57,6 +57,8 @@
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
@ -81,15 +83,13 @@
<false/>
</dict>
</dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSAppleMusicUsageDescription</key>
<string>Session needs to use Apple Music to play media attachments.</string>
<key>NSCameraUsageDescription</key>
<string>Session needs camera access to take pictures and scan QR codes.</string>
<key>NSFaceIDUsageDescription</key>
<string>Session's Screen Lock feature uses Face ID.</string>
<string>Session&apos;s Screen Lock feature uses Face ID.</string>
<key>NSHumanReadableCopyright</key>
<string>com.loki-project.loki-messenger</string>
<key>NSMicrophoneUsageDescription</key>
@ -98,6 +98,8 @@
<string>Session needs access to your library to save photos.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Session needs access to your library to update your avatar and send photos.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Session needs access to local network to make voice and video calls.</string>
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
<key>UIAppFonts</key>
@ -152,5 +154,9 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSBonjourServices</key>
<array>
<string>_session_local_network_access_check._tcp</string>
</array>
</dict>
</plist>

@ -139,7 +139,6 @@ public class SessionApp: SessionAppType {
public func resetData(onReset: (() -> ())) {
homeViewController = nil
LibSession.clearLoggers()
dependencies.remove(cache: .libSession)
dependencies.mutate(cache: .libSessionNetwork) {
$0.clearSnodeCache()
@ -152,6 +151,7 @@ public class SessionApp: SessionAppType {
try? dependencies[singleton: .keychain].removeAll()
onReset()
LibSession.clearLoggers()
Log.info("Data Reset Complete.")
Log.flush()

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

Loading…
Cancel
Save