Merge branch 'updated-user-config-handling' into disappearing-message-redesign
This commit is contained in:
commit
d81a8743ac
|
@ -6463,7 +6463,7 @@
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 410;
|
CURRENT_PROJECT_VERSION = 411;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||||
|
@ -6535,7 +6535,7 @@
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 410;
|
CURRENT_PROJECT_VERSION = 411;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
@ -6600,7 +6600,7 @@
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 410;
|
CURRENT_PROJECT_VERSION = 411;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||||
|
@ -6674,7 +6674,7 @@
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 410;
|
CURRENT_PROJECT_VERSION = 411;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
@ -7582,7 +7582,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
CURRENT_PROJECT_VERSION = 410;
|
CURRENT_PROJECT_VERSION = 411;
|
||||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
@ -7653,7 +7653,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
CURRENT_PROJECT_VERSION = 410;
|
CURRENT_PROJECT_VERSION = 411;
|
||||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|
|
@ -246,11 +246,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||||
)
|
)
|
||||||
// Start the timeout timer for the call
|
// Start the timeout timer for the call
|
||||||
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
|
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
|
||||||
.flatMap { _ in
|
.flatMap { _ in webRTCSession.sendOffer(to: thread) }
|
||||||
Storage.shared.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
|
||||||
webRTCSession.sendOffer(db, to: sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sinkUntilComplete()
|
.sinkUntilComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -431,10 +427,12 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||||
let sessionId: String = self.sessionId
|
let sessionId: String = self.sessionId
|
||||||
let webRTCSession: WebRTCSession = self.webRTCSession
|
let webRTCSession: WebRTCSession = self.webRTCSession
|
||||||
|
|
||||||
Storage.shared
|
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: sessionId) }) else {
|
||||||
.readPublisherFlatMap { db in
|
return
|
||||||
webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true)
|
}
|
||||||
}
|
|
||||||
|
webRTCSession
|
||||||
|
.sendOffer(to: thread, isRestartingICEConnection: true)
|
||||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
.sinkUntilComplete()
|
.sinkUntilComplete()
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import YYImage
|
import YYImage
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import WebRTC
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebRTC
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
|
||||||
public protocol VideoPreviewDelegate: AnyObject {
|
public protocol VideoPreviewDelegate: AnyObject {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebRTC
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
@ -114,7 +113,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||||
publicKey: call.sessionId,
|
publicKey: call.sessionId,
|
||||||
threadVariant: .contact,
|
threadVariant: .contact,
|
||||||
customImageData: nil,
|
customImageData: nil,
|
||||||
profile: Profile.fetchOrCreate(id: call.sessionId),
|
profile: Storage.shared.read { db in Profile.fetchOrCreate(db, id: call.sessionId) },
|
||||||
additionalProfile: nil
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
displayNameLabel.text = call.contactName
|
displayNameLabel.text = call.contactName
|
||||||
|
|
|
@ -464,21 +464,19 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
|
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||||
Storage.shared
|
Storage.shared
|
||||||
.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
.writePublisher { db in
|
||||||
if !updatedMemberIds.contains(userPublicKey) {
|
// If the user is no longer a member then leave the group
|
||||||
try MessageSender.leave(
|
guard !updatedMemberIds.contains(userPublicKey) else { return }
|
||||||
db,
|
|
||||||
groupPublicKey: threadId,
|
try MessageSender.leave(
|
||||||
deleteThread: true
|
|
||||||
)
|
|
||||||
|
|
||||||
return Just(())
|
|
||||||
.setFailureType(to: Error.self)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
return MessageSender.update(
|
|
||||||
db,
|
db,
|
||||||
|
groupPublicKey: threadId,
|
||||||
|
deleteThread: true
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
.flatMap {
|
||||||
|
MessageSender.update(
|
||||||
groupPublicKey: threadId,
|
groupPublicKey: threadId,
|
||||||
with: updatedMemberIds,
|
with: updatedMemberIds,
|
||||||
name: updatedName
|
name: updatedName
|
||||||
|
|
|
@ -332,10 +332,8 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
||||||
let selectedContacts = self.selectedContacts
|
let selectedContacts = self.selectedContacts
|
||||||
let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil)
|
let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil)
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
||||||
Storage.shared
|
MessageSender
|
||||||
.writePublisherFlatMap { db in
|
.createClosedGroup(name: name, members: selectedContacts)
|
||||||
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
|
|
||||||
}
|
|
||||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
|
|
|
@ -1212,7 +1212,12 @@ extension ConversationVC:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) {
|
func react(
|
||||||
|
_ cellViewModel: MessageViewModel,
|
||||||
|
with emoji: String,
|
||||||
|
remove: Bool,
|
||||||
|
using dependencies: Dependencies = Dependencies()
|
||||||
|
) {
|
||||||
guard
|
guard
|
||||||
self.viewModel.threadData.threadIsMessageRequest != true && (
|
self.viewModel.threadData.threadIsMessageRequest != true && (
|
||||||
cellViewModel.variant == .standardIncoming ||
|
cellViewModel.variant == .standardIncoming ||
|
||||||
|
@ -1224,7 +1229,7 @@ extension ConversationVC:
|
||||||
let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
|
let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
|
||||||
let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken
|
let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken
|
||||||
let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
||||||
let recentReactionTimestamps: [Int64] = General.cache.wrappedValue.recentReactionTimestamps
|
let recentReactionTimestamps: [Int64] = dependencies.generalCache.recentReactionTimestamps
|
||||||
|
|
||||||
guard
|
guard
|
||||||
recentReactionTimestamps.count < 20 ||
|
recentReactionTimestamps.count < 20 ||
|
||||||
|
@ -1242,7 +1247,7 @@ extension ConversationVC:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
General.cache.mutate {
|
dependencies.mutableGeneralCache.mutate {
|
||||||
$0.recentReactionTimestamps = Array($0.recentReactionTimestamps
|
$0.recentReactionTimestamps = Array($0.recentReactionTimestamps
|
||||||
.suffix(19))
|
.suffix(19))
|
||||||
.appending(sentTimestamp)
|
.appending(sentTimestamp)
|
||||||
|
@ -1553,7 +1558,7 @@ extension ConversationVC:
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage.shared
|
Storage.shared
|
||||||
.writePublisherFlatMap { db in
|
.writePublisher { db in
|
||||||
OpenGroupManager.shared.add(
|
OpenGroupManager.shared.add(
|
||||||
db,
|
db,
|
||||||
roomToken: room,
|
roomToken: room,
|
||||||
|
@ -1562,6 +1567,15 @@ extension ConversationVC:
|
||||||
calledFromConfigHandling: false
|
calledFromConfigHandling: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.flatMap { successfullyAddedGroup in
|
||||||
|
OpenGroupManager.shared.performInitialRequestsAfterAdd(
|
||||||
|
successfullyAddedGroup: successfullyAddedGroup,
|
||||||
|
roomToken: room,
|
||||||
|
server: server,
|
||||||
|
publicKey: publicKey,
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
|
}
|
||||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
|
@ -1569,6 +1583,18 @@ extension ConversationVC:
|
||||||
switch result {
|
switch result {
|
||||||
case .finished: break
|
case .finished: break
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
// If there was a failure then the group will be in invalid state until
|
||||||
|
// the next launch so remove it (the user will be left on the previous
|
||||||
|
// screen so can re-trigger the join)
|
||||||
|
Storage.shared.writeAsync { db in
|
||||||
|
OpenGroupManager.shared.delete(
|
||||||
|
db,
|
||||||
|
openGroupId: OpenGroup.idFor(roomToken: room, server: server),
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the user an error indicating they failed to properly join the group
|
||||||
let errorModal: ConfirmationModal = ConfirmationModal(
|
let errorModal: ConfirmationModal = ConfirmationModal(
|
||||||
info: ConfirmationModal.Info(
|
info: ConfirmationModal.Info(
|
||||||
title: "COMMUNITY_ERROR_GENERIC".localized(),
|
title: "COMMUNITY_ERROR_GENERIC".localized(),
|
||||||
|
|
|
@ -13,7 +13,9 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
private static let loadingHeaderHeight: CGFloat = 40
|
private static let loadingHeaderHeight: CGFloat = 40
|
||||||
|
|
||||||
internal let viewModel: ConversationViewModel
|
internal let viewModel: ConversationViewModel
|
||||||
private var dataChangeObservable: DatabaseCancellable?
|
private var dataChangeObservable: DatabaseCancellable? {
|
||||||
|
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
||||||
|
}
|
||||||
private var hasLoadedInitialThreadData: Bool = false
|
private var hasLoadedInitialThreadData: Bool = false
|
||||||
private var hasLoadedInitialInteractionData: Bool = false
|
private var hasLoadedInitialInteractionData: Bool = false
|
||||||
private var currentTargetOffset: CGPoint?
|
private var currentTargetOffset: CGPoint?
|
||||||
|
@ -538,6 +540,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
/// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the
|
||||||
|
/// main thread (we don't currently care if it's still in the nav stack though - so if a user is on a conversation settings screen this should
|
||||||
|
/// get cleared within `viewWillDisappear`)
|
||||||
|
///
|
||||||
|
/// **Note:** We do this on an async queue because `Atomic<T>` can block if something else is mutating it and we want to avoid
|
||||||
|
/// the risk of blocking the conversation transition
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
SessionApp.currentlyOpenConversationViewController.mutate { $0 = self }
|
||||||
|
}
|
||||||
|
|
||||||
if delayFirstResponder || isShowingSearchUI {
|
if delayFirstResponder || isShowingSearchUI {
|
||||||
delayFirstResponder = false
|
delayFirstResponder = false
|
||||||
|
|
||||||
|
@ -560,6 +572,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
/// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the
|
||||||
|
/// main thread (we don't currently care if it's still in the nav stack though - so if a user leaves a conversation settings screen we clear
|
||||||
|
/// it, and if a user moves to a different `ConversationVC` this will get updated to that one within `viewDidAppear`)
|
||||||
|
///
|
||||||
|
/// **Note:** We do this on an async queue because `Atomic<T>` can block if something else is mutating it and we want to avoid
|
||||||
|
/// the risk of blocking the conversation transition
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
SessionApp.currentlyOpenConversationViewController.mutate { $0 = nil }
|
||||||
|
}
|
||||||
|
|
||||||
viewIsDisappearing = true
|
viewIsDisappearing = true
|
||||||
|
|
||||||
// Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard
|
// Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard
|
||||||
|
@ -625,7 +647,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
// MARK: - Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||||
// Start observing for data changes
|
guard dataChangeObservable == nil else { return }
|
||||||
|
|
||||||
dataChangeObservable = Storage.shared.start(
|
dataChangeObservable = Storage.shared.start(
|
||||||
viewModel.observableThreadData,
|
viewModel.observableThreadData,
|
||||||
onError: { _ in },
|
onError: { _ in },
|
||||||
|
@ -695,8 +718,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopObservingChanges() {
|
func stopObservingChanges() {
|
||||||
// Stop observing database changes
|
self.dataChangeObservable = nil
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
self.viewModel.onInteractionChange = nil
|
self.viewModel.onInteractionChange = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -63,6 +63,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
|
|
||||||
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
|
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
|
||||||
typealias InitialData = (
|
typealias InitialData = (
|
||||||
|
currentUserPublicKey: String,
|
||||||
initialUnreadInteractionInfo: Interaction.TimestampInfo?,
|
initialUnreadInteractionInfo: Interaction.TimestampInfo?,
|
||||||
threadIsBlocked: Bool,
|
threadIsBlocked: Bool,
|
||||||
currentUserIsClosedGroupMember: Bool?,
|
currentUserIsClosedGroupMember: Bool?,
|
||||||
|
@ -73,6 +74,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
|
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
|
||||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||||
|
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
|
|
||||||
// If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
|
// If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
|
||||||
// unread interaction and start focused around that one
|
// unread interaction and start focused around that one
|
||||||
|
@ -94,7 +96,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil :
|
let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil :
|
||||||
GroupMember
|
GroupMember
|
||||||
.filter(groupMember[.groupId] == threadId)
|
.filter(groupMember[.groupId] == threadId)
|
||||||
.filter(groupMember[.profileId] == getUserHexEncodedPublicKey(db))
|
.filter(groupMember[.profileId] == currentUserPublicKey)
|
||||||
.filter(groupMember[.role] == GroupMember.Role.standard)
|
.filter(groupMember[.role] == GroupMember.Role.standard)
|
||||||
.isNotEmpty(db)
|
.isNotEmpty(db)
|
||||||
)
|
)
|
||||||
|
@ -112,6 +114,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
currentUserPublicKey,
|
||||||
initialUnreadInteractionInfo,
|
initialUnreadInteractionInfo,
|
||||||
threadIsBlocked,
|
threadIsBlocked,
|
||||||
currentUserIsClosedGroupMember,
|
currentUserIsClosedGroupMember,
|
||||||
|
@ -128,7 +131,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
self.threadData = SessionThreadViewModel(
|
self.threadData = SessionThreadViewModel(
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
threadVariant: threadVariant,
|
threadVariant: threadVariant,
|
||||||
threadIsNoteToSelf: (self.threadId == getUserHexEncodedPublicKey()),
|
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
|
||||||
threadIsBlocked: initialData?.threadIsBlocked,
|
threadIsBlocked: initialData?.threadIsBlocked,
|
||||||
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
|
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
|
||||||
openGroupPermissions: initialData?.openGroupPermissions
|
openGroupPermissions: initialData?.openGroupPermissions
|
||||||
|
@ -141,7 +144,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
// distinct stutter)
|
// distinct stutter)
|
||||||
self.pagedDataObserver = self.setupPagedObserver(
|
self.pagedDataObserver = self.setupPagedObserver(
|
||||||
for: threadId,
|
for: threadId,
|
||||||
userPublicKey: getUserHexEncodedPublicKey(),
|
userPublicKey: (initialData?.currentUserPublicKey ?? getUserHexEncodedPublicKey()),
|
||||||
blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey(
|
blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey(
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
threadVariant: threadVariant
|
threadVariant: threadVariant
|
||||||
|
@ -217,8 +220,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
didSet {
|
didSet {
|
||||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||||
// data was changed while we weren't observing
|
// data was changed while we weren't observing
|
||||||
if let unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
|
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
|
||||||
onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1)
|
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onInteractionChange
|
||||||
|
|
||||||
|
switch Thread.isMainThread {
|
||||||
|
case true: performChange?(changes.0, changes.1)
|
||||||
|
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
|
||||||
|
}
|
||||||
|
|
||||||
self.unobservedInteractionDataChanges = nil
|
self.unobservedInteractionDataChanges = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,7 @@ public final class InputTextView: UITextView, UITextViewDelegate {
|
||||||
|
|
||||||
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
if action == #selector(paste(_:)) {
|
if action == #selector(paste(_:)) {
|
||||||
if let _ = UIPasteboard.general.image {
|
if UIPasteboard.general.hasImages {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,9 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
||||||
public static let newConversationButtonSize: CGFloat = 60
|
public static let newConversationButtonSize: CGFloat = 60
|
||||||
|
|
||||||
private let viewModel: HomeViewModel = HomeViewModel()
|
private let viewModel: HomeViewModel = HomeViewModel()
|
||||||
private var dataChangeObservable: DatabaseCancellable?
|
private var dataChangeObservable: DatabaseCancellable? {
|
||||||
|
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
||||||
|
}
|
||||||
private var hasLoadedInitialStateData: Bool = false
|
private var hasLoadedInitialStateData: Bool = false
|
||||||
private var hasLoadedInitialThreadData: Bool = false
|
private var hasLoadedInitialThreadData: Bool = false
|
||||||
private var isLoadingMore: Bool = false
|
private var isLoadingMore: Bool = false
|
||||||
|
@ -226,7 +228,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
||||||
// Preparation
|
// Preparation
|
||||||
SessionApp.homeViewController.mutate { $0 = self }
|
SessionApp.homeViewController.mutate { $0 = self }
|
||||||
|
|
||||||
updateNavBarButtons()
|
updateNavBarButtons(userProfile: self.viewModel.state.userProfile)
|
||||||
setUpNavBarSessionHeading()
|
setUpNavBarSessionHeading()
|
||||||
|
|
||||||
// Recovery phrase reminder
|
// Recovery phrase reminder
|
||||||
|
@ -327,26 +329,31 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
||||||
|
|
||||||
// MARK: - Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
public func startObservingChanges(didReturnFromBackground: Bool = false, onReceivedInitialChange: (() -> ())? = nil) {
|
||||||
// Start observing for data changes
|
guard dataChangeObservable == nil else { return }
|
||||||
|
|
||||||
|
var runAndClearInitialChangeCallback: (() -> ())? = nil
|
||||||
|
|
||||||
|
runAndClearInitialChangeCallback = { [weak self] in
|
||||||
|
guard self?.hasLoadedInitialStateData == true && self?.hasLoadedInitialThreadData == true else { return }
|
||||||
|
|
||||||
|
onReceivedInitialChange?()
|
||||||
|
runAndClearInitialChangeCallback = nil
|
||||||
|
}
|
||||||
|
|
||||||
dataChangeObservable = Storage.shared.start(
|
dataChangeObservable = Storage.shared.start(
|
||||||
viewModel.observableState,
|
viewModel.observableState,
|
||||||
// If we haven't done the initial load the trigger it immediately (blocking the main
|
|
||||||
// thread so we remain on the launch screen until it completes to be consistent with
|
|
||||||
// the old behaviour)
|
|
||||||
scheduling: (hasLoadedInitialStateData ?
|
|
||||||
.async(onQueue: .main) :
|
|
||||||
.immediate
|
|
||||||
),
|
|
||||||
onError: { _ in },
|
onError: { _ in },
|
||||||
onChange: { [weak self] state in
|
onChange: { [weak self] state in
|
||||||
// The default scheduler emits changes on the main thread
|
// The default scheduler emits changes on the main thread
|
||||||
self?.handleUpdates(state)
|
self?.handleUpdates(state)
|
||||||
|
runAndClearInitialChangeCallback?()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
||||||
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
||||||
|
runAndClearInitialChangeCallback?()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: When returning from the background we could have received notifications but the
|
// Note: When returning from the background we could have received notifications but the
|
||||||
|
@ -361,7 +368,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
||||||
|
|
||||||
private func stopObservingChanges() {
|
private func stopObservingChanges() {
|
||||||
// Stop observing database changes
|
// Stop observing database changes
|
||||||
dataChangeObservable?.cancel()
|
self.dataChangeObservable = nil
|
||||||
self.viewModel.onThreadChange = nil
|
self.viewModel.onThreadChange = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -375,7 +382,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
||||||
}
|
}
|
||||||
|
|
||||||
if updatedState.userProfile != self.viewModel.state.userProfile {
|
if updatedState.userProfile != self.viewModel.state.userProfile {
|
||||||
updateNavBarButtons()
|
updateNavBarButtons(userProfile: updatedState.userProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the 'view seed' UI
|
// Update the 'view seed' UI
|
||||||
|
@ -482,17 +489,17 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateNavBarButtons() {
|
private func updateNavBarButtons(userProfile: Profile) {
|
||||||
// Profile picture view
|
// Profile picture view
|
||||||
let profilePictureView = ProfilePictureView(size: .navigation)
|
let profilePictureView = ProfilePictureView(size: .navigation)
|
||||||
profilePictureView.accessibilityIdentifier = "User settings"
|
profilePictureView.accessibilityIdentifier = "User settings"
|
||||||
profilePictureView.accessibilityLabel = "User settings"
|
profilePictureView.accessibilityLabel = "User settings"
|
||||||
profilePictureView.isAccessibilityElement = true
|
profilePictureView.isAccessibilityElement = true
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: getUserHexEncodedPublicKey(),
|
publicKey: userProfile.id,
|
||||||
threadVariant: .contact,
|
threadVariant: .contact,
|
||||||
customImageData: nil,
|
customImageData: nil,
|
||||||
profile: Profile.fetchOrCreateCurrentUser(),
|
profile: userProfile,
|
||||||
additionalProfile: nil
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -26,32 +26,39 @@ public class HomeViewModel {
|
||||||
let showViewedSeedBanner: Bool
|
let showViewedSeedBanner: Bool
|
||||||
let hasHiddenMessageRequests: Bool
|
let hasHiddenMessageRequests: Bool
|
||||||
let unreadMessageRequestThreadCount: Int
|
let unreadMessageRequestThreadCount: Int
|
||||||
let userProfile: Profile?
|
let userProfile: Profile
|
||||||
|
|
||||||
init(
|
|
||||||
showViewedSeedBanner: Bool = !Storage.shared[.hasViewedSeed],
|
|
||||||
hasHiddenMessageRequests: Bool = Storage.shared[.hasHiddenMessageRequests],
|
|
||||||
unreadMessageRequestThreadCount: Int = 0,
|
|
||||||
userProfile: Profile? = nil
|
|
||||||
) {
|
|
||||||
self.showViewedSeedBanner = showViewedSeedBanner
|
|
||||||
self.hasHiddenMessageRequests = hasHiddenMessageRequests
|
|
||||||
self.unreadMessageRequestThreadCount = unreadMessageRequestThreadCount
|
|
||||||
self.userProfile = userProfile
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.state = State()
|
typealias InitialData = (
|
||||||
|
showViewedSeedBanner: Bool,
|
||||||
|
hasHiddenMessageRequests: Bool,
|
||||||
|
profile: Profile
|
||||||
|
)
|
||||||
|
|
||||||
|
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
|
||||||
|
(
|
||||||
|
!db[.hasViewedSeed],
|
||||||
|
db[.hasHiddenMessageRequests],
|
||||||
|
Profile.fetchOrCreateCurrentUser(db)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state = State(
|
||||||
|
showViewedSeedBanner: (initialData?.showViewedSeedBanner ?? true),
|
||||||
|
hasHiddenMessageRequests: (initialData?.hasHiddenMessageRequests ?? false),
|
||||||
|
unreadMessageRequestThreadCount: 0,
|
||||||
|
userProfile: (initialData?.profile ?? Profile.fetchOrCreateCurrentUser())
|
||||||
|
)
|
||||||
self.pagedDataObserver = nil
|
self.pagedDataObserver = nil
|
||||||
|
|
||||||
// Note: Since this references self we need to finish initializing before setting it, we
|
// Note: Since this references self we need to finish initializing before setting it, we
|
||||||
// also want to skip the initial query and trigger it async so that the push animation
|
// also want to skip the initial query and trigger it async so that the push animation
|
||||||
// doesn't stutter (it should load basically immediately but without this there is a
|
// doesn't stutter (it should load basically immediately but without this there is a
|
||||||
// distinct stutter)
|
// distinct stutter)
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
let userPublicKey: String = self.state.userProfile.id
|
||||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||||
self.pagedDataObserver = PagedDatabaseObserver(
|
self.pagedDataObserver = PagedDatabaseObserver(
|
||||||
pagedTable: SessionThread.self,
|
pagedTable: SessionThread.self,
|
||||||
|
@ -208,12 +215,16 @@ public class HomeViewModel {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self?.hasReceivedInitialThreadData = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run the initial query on the main thread so we prevent the app from leaving the loading screen
|
// Run the initial query on a background thread so we don't block the main thread
|
||||||
// until we have data (Note: the `.pageBefore` will query from a `0` offset loading the first page)
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
self.pagedDataObserver?.load(.pageBefore)
|
// The `.pageBefore` will query from a `0` offset loading the first page
|
||||||
|
self?.pagedDataObserver?.load(.pageBefore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - State
|
||||||
|
@ -254,8 +265,10 @@ public class HomeViewModel {
|
||||||
let oldState: State = self.state
|
let oldState: State = self.state
|
||||||
self.state = updatedState
|
self.state = updatedState
|
||||||
|
|
||||||
// If the messageRequest content changed then we need to re-process the thread data
|
// If the messageRequest content changed then we need to re-process the thread data (assuming
|
||||||
|
// we've received the initial thread data)
|
||||||
guard
|
guard
|
||||||
|
self.hasReceivedInitialThreadData,
|
||||||
(
|
(
|
||||||
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
|
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
|
||||||
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
|
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
|
||||||
|
@ -272,11 +285,7 @@ public class HomeViewModel {
|
||||||
|
|
||||||
PagedData.processAndTriggerUpdates(
|
PagedData.processAndTriggerUpdates(
|
||||||
updatedData: updatedThreadData,
|
updatedData: updatedThreadData,
|
||||||
currentDataRetriever: { [weak self] in
|
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
|
||||||
guard self?.hasProcessedInitialThreadData == true else { return nil }
|
|
||||||
|
|
||||||
return (self?.unobservedThreadDataChanges?.0 ?? self?.threadData)
|
|
||||||
},
|
|
||||||
onDataChange: onThreadChange,
|
onDataChange: onThreadChange,
|
||||||
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
||||||
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
||||||
|
@ -289,19 +298,23 @@ public class HomeViewModel {
|
||||||
|
|
||||||
// MARK: - Thread Data
|
// MARK: - Thread Data
|
||||||
|
|
||||||
private var hasProcessedInitialThreadData: Bool = false
|
private var hasReceivedInitialThreadData: Bool = false
|
||||||
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||||
public private(set) var threadData: [SectionModel] = []
|
public private(set) var threadData: [SectionModel] = []
|
||||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||||
|
|
||||||
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
||||||
didSet {
|
didSet {
|
||||||
self.hasProcessedInitialThreadData = (onThreadChange != nil || hasProcessedInitialThreadData)
|
|
||||||
|
|
||||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||||
// data was changed while we weren't observing
|
// data was changed while we weren't observing
|
||||||
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||||
onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
|
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange
|
||||||
|
|
||||||
|
switch Thread.isMainThread {
|
||||||
|
case true: performChange?(changes.0, changes.1)
|
||||||
|
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
|
||||||
|
}
|
||||||
|
|
||||||
self.unobservedThreadDataChanges = nil
|
self.unobservedThreadDataChanges = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
||||||
private static let loadingHeaderHeight: CGFloat = 40
|
private static let loadingHeaderHeight: CGFloat = 40
|
||||||
|
|
||||||
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
||||||
private var dataChangeObservable: DatabaseCancellable?
|
|
||||||
private var hasLoadedInitialThreadData: Bool = false
|
private var hasLoadedInitialThreadData: Bool = false
|
||||||
private var isLoadingMore: Bool = false
|
private var isLoadingMore: Bool = false
|
||||||
private var isAutoLoadingNextPage: Bool = false
|
private var isAutoLoadingNextPage: Bool = false
|
||||||
|
@ -161,8 +160,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||||
|
@ -173,8 +171,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layout
|
// MARK: - Layout
|
||||||
|
@ -223,6 +220,10 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stopObservingChanges() {
|
||||||
|
self.viewModel.onThreadChange = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func handleThreadUpdates(
|
private func handleThreadUpdates(
|
||||||
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
||||||
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
|
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
|
||||||
|
|
|
@ -129,8 +129,14 @@ public class MessageRequestsViewModel {
|
||||||
didSet {
|
didSet {
|
||||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||||
// data was changed while we weren't observing
|
// data was changed while we weren't observing
|
||||||
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||||
self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
|
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange
|
||||||
|
|
||||||
|
switch Thread.isMainThread {
|
||||||
|
case true: performChange?(changes.0, changes.1)
|
||||||
|
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
|
||||||
|
}
|
||||||
|
|
||||||
self.unobservedThreadDataChanges = nil
|
self.unobservedThreadDataChanges = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -203,8 +203,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
let scale = UIScreen.main.scale
|
let scale = UIScreen.main.scale
|
||||||
let cellSize = collectionViewFlowLayout.itemSize
|
let cellSize = collectionViewFlowLayout.itemSize
|
||||||
photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
||||||
|
|
||||||
reloadDataAndRestoreSelection()
|
|
||||||
if !hasEverAppeared {
|
if !hasEverAppeared {
|
||||||
scrollToBottom(animated: false)
|
scrollToBottom(animated: false)
|
||||||
}
|
}
|
||||||
|
@ -291,30 +290,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadDataAndRestoreSelection() {
|
|
||||||
guard let collectionView = collectionView else {
|
|
||||||
owsFailDebug("Missing collectionView.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let delegate = delegate else {
|
|
||||||
owsFailDebug("delegate was unexpectedly nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
collectionView.reloadData()
|
|
||||||
collectionView.layoutIfNeeded()
|
|
||||||
|
|
||||||
let count = photoCollectionContents.assetCount
|
|
||||||
for index in 0..<count {
|
|
||||||
let asset = photoCollectionContents.asset(at: index)
|
|
||||||
if delegate.imagePicker(self, isAssetSelected: asset) {
|
|
||||||
collectionView.selectItem(at: IndexPath(row: index, section: 0),
|
|
||||||
animated: false, scrollPosition: [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
|
@ -365,7 +340,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
}
|
}
|
||||||
|
|
||||||
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
||||||
reloadDataAndRestoreSelection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearCollectionViewSelection() {
|
func clearCollectionViewSelection() {
|
||||||
|
@ -402,7 +376,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
|
|
||||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||||
photoCollectionContents = photoCollection.contents()
|
photoCollectionContents = photoCollection.contents()
|
||||||
reloadDataAndRestoreSelection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PhotoCollectionPicker Presentation
|
// MARK: - PhotoCollectionPicker Presentation
|
||||||
|
|
|
@ -48,8 +48,14 @@ public class MediaGalleryViewModel {
|
||||||
didSet {
|
didSet {
|
||||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||||
// data was changed while we weren't observing
|
// data was changed while we weren't observing
|
||||||
if let unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
|
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
|
||||||
onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1)
|
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onGalleryChange
|
||||||
|
|
||||||
|
switch Thread.isMainThread {
|
||||||
|
case true: performChange?(changes.0, changes.1)
|
||||||
|
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
|
||||||
|
}
|
||||||
|
|
||||||
self.unobservedGalleryDataChanges = nil
|
self.unobservedGalleryDataChanges = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss?
|
fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss?
|
||||||
|
|
||||||
public let viewModel: MediaGalleryViewModel
|
public let viewModel: MediaGalleryViewModel
|
||||||
private var dataChangeObservable: DatabaseCancellable?
|
private var dataChangeObservable: DatabaseCancellable? {
|
||||||
|
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
||||||
|
}
|
||||||
private var initialPage: MediaDetailViewController
|
private var initialPage: MediaDetailViewController
|
||||||
private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:]
|
private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:]
|
||||||
|
|
||||||
|
@ -40,7 +42,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
)
|
)
|
||||||
|
|
||||||
// Swap out the database observer
|
// Swap out the database observer
|
||||||
dataChangeObservable?.cancel()
|
stopObservingChanges()
|
||||||
viewModel.replaceAlbumObservation(toObservationFor: item.interactionId)
|
viewModel.replaceAlbumObservation(toObservationFor: item.interactionId)
|
||||||
startObservingChanges()
|
startObservingChanges()
|
||||||
|
|
||||||
|
@ -238,8 +240,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
|
|
||||||
resignFirstResponder()
|
resignFirstResponder()
|
||||||
}
|
}
|
||||||
|
@ -252,8 +253,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
@ -388,6 +388,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
// MARK: - Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
private func startObservingChanges() {
|
private func startObservingChanges() {
|
||||||
|
guard dataChangeObservable == nil else { return }
|
||||||
|
|
||||||
// Start observing for data changes
|
// Start observing for data changes
|
||||||
dataChangeObservable = Storage.shared.start(
|
dataChangeObservable = Storage.shared.start(
|
||||||
viewModel.observableAlbumData,
|
viewModel.observableAlbumData,
|
||||||
|
@ -399,6 +401,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stopObservingChanges() {
|
||||||
|
dataChangeObservable = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) {
|
private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) {
|
||||||
// Determine if we swapped albums (if so we don't need to do anything else)
|
// Determine if we swapped albums (if so we don't need to do anything else)
|
||||||
guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else {
|
guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else {
|
||||||
|
@ -710,7 +716,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap out the database observer
|
// Swap out the database observer
|
||||||
dataChangeObservable?.cancel()
|
stopObservingChanges()
|
||||||
viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter)
|
viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter)
|
||||||
startObservingChanges()
|
startObservingChanges()
|
||||||
|
|
||||||
|
@ -755,7 +761,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap out the database observer
|
// Swap out the database observer
|
||||||
dataChangeObservable?.cancel()
|
stopObservingChanges()
|
||||||
viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore)
|
viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore)
|
||||||
startObservingChanges()
|
startObservingChanges()
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import GRDB
|
import GRDB
|
||||||
import WebRTC
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
@ -13,23 +12,25 @@ import SignalCoreKit
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
|
private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 10
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
var backgroundSnapshotBlockerWindow: UIWindow?
|
var backgroundSnapshotBlockerWindow: UIWindow?
|
||||||
var appStartupWindow: UIWindow?
|
var appStartupWindow: UIWindow?
|
||||||
var hasInitialRootViewController: Bool = false
|
var hasInitialRootViewController: Bool = false
|
||||||
|
var startTime: CFTimeInterval = 0
|
||||||
private var loadingViewController: LoadingViewController?
|
private var loadingViewController: LoadingViewController?
|
||||||
|
|
||||||
enum LifecycleMethod {
|
|
||||||
case finishLaunching
|
|
||||||
case enterForeground
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
|
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
|
||||||
lazy var poller: CurrentUserPoller = CurrentUserPoller()
|
lazy var poller: CurrentUserPoller = CurrentUserPoller()
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
// Log something immediately to make it easier to track app launches (and crashes during launch)
|
||||||
|
SNLog("Launching \(SessionApp.versionInfo)")
|
||||||
|
startTime = CACurrentMediaTime()
|
||||||
|
|
||||||
// These should be the first things we do (the startup process can fail without them)
|
// These should be the first things we do (the startup process can fail without them)
|
||||||
SetCurrentAppContext(MainAppContext())
|
SetCurrentAppContext(MainAppContext())
|
||||||
verifyDBKeysAvailableBeforeBackgroundLaunch()
|
verifyDBKeysAvailableBeforeBackgroundLaunch()
|
||||||
|
@ -71,7 +72,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
},
|
},
|
||||||
migrationsCompletion: { [weak self] result, needsConfigSync in
|
migrationsCompletion: { [weak self] result, needsConfigSync in
|
||||||
if case .failure(let error) = result {
|
if case .failure(let error) = result {
|
||||||
self?.showFailedMigrationAlert(calledFrom: .finishLaunching, error: error)
|
DispatchQueue.main.async {
|
||||||
|
self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .databaseError(error))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,7 +150,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
},
|
},
|
||||||
migrationsCompletion: { [weak self] result, needsConfigSync in
|
migrationsCompletion: { [weak self] result, needsConfigSync in
|
||||||
if case .failure(let error) = result {
|
if case .failure(let error) = result {
|
||||||
self?.showFailedMigrationAlert(calledFrom: .enterForeground, error: error)
|
DispatchQueue.main.async {
|
||||||
|
self?.showFailedStartupAlert(calledFrom: .enterForeground, error: .databaseError(error))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -187,7 +192,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
|
|
||||||
UserDefaults.sharedLokiProject?[.isMainAppActive] = true
|
UserDefaults.sharedLokiProject?[.isMainAppActive] = true
|
||||||
|
|
||||||
ensureRootViewController()
|
ensureRootViewController(calledFrom: .didBecomeActive)
|
||||||
|
|
||||||
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
|
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
|
||||||
self?.handleActivation()
|
self?.handleActivation()
|
||||||
|
@ -283,122 +288,146 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
Configuration.performMainSetup()
|
Configuration.performMainSetup()
|
||||||
JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens)
|
JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens)
|
||||||
|
|
||||||
/// Setup the UI
|
// Setup the UI and trigger any post-UI setup actions
|
||||||
///
|
self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self] in
|
||||||
/// **Note:** This **MUST** be run before calling:
|
/// Trigger any launch-specific jobs and start the JobRunner with `JobRunner.appDidFinishLaunching()` some
|
||||||
/// - `AppReadiness.setAppIsReady()`:
|
/// of these jobs (eg. DisappearingMessages job) can impact the interactions which get fetched to display on the home
|
||||||
/// If we are launching the app from a push notification the HomeVC won't be setup yet
|
/// screen, if the PagedDatabaseObserver hasn't been setup yet then the home screen can show stale (ie. deleted)
|
||||||
/// and it won't open the related thread
|
/// interactions incorrectly
|
||||||
///
|
if lifecycleMethod == .finishLaunching {
|
||||||
/// - `JobRunner.appDidFinishLaunching()`:
|
JobRunner.appDidFinishLaunching()
|
||||||
/// The jobs which run on launch (eg. DisappearingMessages job) can impact the interactions
|
}
|
||||||
/// which get fetched to display on the home screen, if the PagedDatabaseObserver hasn't
|
|
||||||
/// been setup yet then the home screen can show stale (ie. deleted) interactions incorrectly
|
|
||||||
self.ensureRootViewController(isPreAppReadyCall: true)
|
|
||||||
|
|
||||||
// Trigger any launch-specific jobs and start the JobRunner
|
|
||||||
if lifecycleMethod == .finishLaunching {
|
|
||||||
JobRunner.appDidFinishLaunching()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note that this does much more than set a flag;
|
|
||||||
// it will also run all deferred blocks (including the JobRunner
|
|
||||||
// 'appDidBecomeActive' method)
|
|
||||||
AppReadiness.setAppIsReady()
|
|
||||||
|
|
||||||
DeviceSleepManager.sharedInstance.removeBlock(blockObject: self)
|
|
||||||
AppVersion.sharedInstance().mainAppLaunchDidComplete()
|
|
||||||
Environment.shared?.audioSession.setup()
|
|
||||||
Environment.shared?.reachabilityManager.setup()
|
|
||||||
|
|
||||||
Storage.shared.writeAsync { db in
|
|
||||||
// Disable the SAE until the main app has successfully completed launch process
|
|
||||||
// at least once in the post-SAE world.
|
|
||||||
db[.isReadyForAppExtensions] = true
|
|
||||||
|
|
||||||
if Identity.userCompletedRequiredOnboarding(db) {
|
/// Flag that the app is ready via `AppReadiness.setAppIsReady()`
|
||||||
let appVersion: AppVersion = AppVersion.sharedInstance()
|
///
|
||||||
|
/// If we are launching the app from a push notification we need to ensure we wait until after the `HomeVC` is setup
|
||||||
|
/// otherwise it won't open the related thread
|
||||||
|
///
|
||||||
|
/// **Note:** This this does much more than set a flag - it will also run all deferred blocks (including the JobRunner
|
||||||
|
/// `appDidBecomeActive` method hence why it **must** also come after calling
|
||||||
|
/// `JobRunner.appDidFinishLaunching()`)
|
||||||
|
AppReadiness.setAppIsReady()
|
||||||
|
|
||||||
|
/// Remove the sleep blocking once the startup is done (needs to run on the main thread and sleeping while
|
||||||
|
/// doing the startup could suspend the database causing errors/crashes
|
||||||
|
DeviceSleepManager.sharedInstance.removeBlock(blockObject: self)
|
||||||
|
|
||||||
|
/// App launch hasn't really completed until the main screen is loaded so wait until then to register it
|
||||||
|
AppVersion.sharedInstance().mainAppLaunchDidComplete()
|
||||||
|
|
||||||
|
/// App won't be ready for extensions and no need to enqueue a config sync unless we successfully completed startup
|
||||||
|
Storage.shared.writeAsync { db in
|
||||||
|
// Disable the SAE until the main app has successfully completed launch process
|
||||||
|
// at least once in the post-SAE world.
|
||||||
|
db[.isReadyForAppExtensions] = true
|
||||||
|
|
||||||
// If the device needs to sync config or the user updated to a new version
|
if Identity.userCompletedRequiredOnboarding(db) {
|
||||||
if
|
let appVersion: AppVersion = AppVersion.sharedInstance()
|
||||||
needsConfigSync || (
|
|
||||||
(appVersion.lastAppVersion?.count ?? 0) > 0 &&
|
// If the device needs to sync config or the user updated to a new version
|
||||||
appVersion.lastAppVersion != appVersion.currentAppVersion
|
if
|
||||||
)
|
needsConfigSync || (
|
||||||
{
|
(appVersion.lastAppVersion?.count ?? 0) > 0 &&
|
||||||
ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
|
appVersion.lastAppVersion != appVersion.currentAppVersion
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a log to track the proper startup time of the app so we know whether we need to
|
||||||
|
// improve it in the future from user logs
|
||||||
|
let endTime: CFTimeInterval = CACurrentMediaTime()
|
||||||
|
SNLog("Launch completed in \((self?.startTime).map { ceil((endTime - $0) * 1000) } ?? -1)ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// May as well run these on the background thread
|
||||||
|
Environment.shared?.audioSession.setup()
|
||||||
|
Environment.shared?.reachabilityManager.setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showFailedMigrationAlert(
|
private func showFailedStartupAlert(
|
||||||
calledFrom lifecycleMethod: LifecycleMethod,
|
calledFrom lifecycleMethod: LifecycleMethod,
|
||||||
error: Error?,
|
error: StartupError,
|
||||||
isRestoreError: Bool = false
|
animated: Bool = true,
|
||||||
|
presentationCompletion: (() -> ())? = nil
|
||||||
) {
|
) {
|
||||||
let alert = UIAlertController(
|
/// This **must** be a standard `UIAlertController` instead of a `ConfirmationModal` because we may not
|
||||||
|
/// have access to the database when displaying this so can't extract theme information for styling purposes
|
||||||
|
let alert: UIAlertController = UIAlertController(
|
||||||
title: "Session",
|
title: "Session",
|
||||||
message: {
|
message: error.message,
|
||||||
switch (isRestoreError, (error ?? StorageError.generic)) {
|
|
||||||
case (true, _): return "DATABASE_RESTORE_FAILED".localized()
|
|
||||||
case (_, StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized()
|
|
||||||
default: return "DATABASE_MIGRATION_FAILED".localized()
|
|
||||||
}
|
|
||||||
}(),
|
|
||||||
preferredStyle: .alert
|
preferredStyle: .alert
|
||||||
)
|
)
|
||||||
alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in
|
alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in
|
||||||
HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in
|
HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in
|
||||||
self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error)
|
// Don't bother showing the "Failed Startup" modal again if we happen to now
|
||||||
|
// have an initial view controller (this most likely means that the startup
|
||||||
|
// completed while the user was sharing logs so we can just let the user use
|
||||||
|
// the app)
|
||||||
|
guard self?.hasInitialRootViewController == false else { return }
|
||||||
|
|
||||||
|
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only offer the 'Restore' option if the user hasn't already tried to restore
|
switch error {
|
||||||
if !isRestoreError {
|
// Don't offer the 'Restore' option if it was a 'startupFailed' error as a restore is unlikely to
|
||||||
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
|
// resolve it (most likely the database is locked or the key was somehow lost - safer to get them
|
||||||
if SUKLegacy.hasLegacyDatabaseFile {
|
// to restart and manually reinstall/restore)
|
||||||
// Remove the legacy database and any message hashes that have been migrated to the new DB
|
case .databaseError(StorageError.startupFailed): break
|
||||||
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
|
|
||||||
|
|
||||||
Storage.shared.write { db in
|
|
||||||
try SnodeReceivedMessageInfo.deleteAll(db)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// If we don't have a legacy database then reset the current database for a clean migration
|
|
||||||
Storage.resetForCleanMigration()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the top banner if there was one
|
// Offer the 'Restore' option if it was a migration error
|
||||||
TopBannerController.hide()
|
case .databaseError:
|
||||||
|
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
|
||||||
// The re-run the migration (should succeed since there is no data)
|
if SUKLegacy.hasLegacyDatabaseFile {
|
||||||
AppSetup.runPostSetupMigrations(
|
// Remove the legacy database and any message hashes that have been migrated to the new DB
|
||||||
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
|
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
|
||||||
self?.loadingViewController?.updateProgress(
|
|
||||||
progress: progress,
|
|
||||||
minEstimatedTotalTime: minEstimatedTotalTime
|
|
||||||
)
|
|
||||||
},
|
|
||||||
migrationsCompletion: { [weak self] result, needsConfigSync in
|
|
||||||
if case .failure(let error) = result {
|
|
||||||
self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error, isRestoreError: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
|
Storage.shared.write { db in
|
||||||
|
try SnodeReceivedMessageInfo.deleteAll(db)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
else {
|
||||||
})
|
// If we don't have a legacy database then reset the current database for a clean migration
|
||||||
|
Storage.resetForCleanMigration()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the top banner if there was one
|
||||||
|
TopBannerController.hide()
|
||||||
|
|
||||||
|
// The re-run the migration (should succeed since there is no data)
|
||||||
|
AppSetup.runPostSetupMigrations(
|
||||||
|
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
|
||||||
|
self?.loadingViewController?.updateProgress(
|
||||||
|
progress: progress,
|
||||||
|
minEstimatedTotalTime: minEstimatedTotalTime
|
||||||
|
)
|
||||||
|
},
|
||||||
|
migrationsCompletion: { [weak self] result, needsConfigSync in
|
||||||
|
switch result {
|
||||||
|
case .failure:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .success:
|
||||||
|
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
default: break
|
||||||
}
|
}
|
||||||
|
|
||||||
alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in
|
alert.addAction(UIAlertAction(title: "APP_STARTUP_EXIT".localized(), style: .default) { _ in
|
||||||
DDLog.flushLog()
|
DDLog.flushLog()
|
||||||
exit(0)
|
exit(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
self.window?.rootViewController?.present(alert, animated: true, completion: nil)
|
self.window?.rootViewController?.present(alert, animated: animated, completion: presentationCompletion)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user must unlock the device once after reboot before the database encryption key can be accessed.
|
/// The user must unlock the device once after reboot before the database encryption key can be accessed.
|
||||||
|
@ -452,36 +481,101 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureRootViewController(isPreAppReadyCall: Bool = false) {
|
private func ensureRootViewController(
|
||||||
guard (AppReadiness.isAppReady() || isPreAppReadyCall) && Storage.shared.isValid && !hasInitialRootViewController else {
|
calledFrom lifecycleMethod: LifecycleMethod,
|
||||||
|
onComplete: (() -> ())? = nil
|
||||||
|
) {
|
||||||
|
guard (AppReadiness.isAppReady() || lifecycleMethod == .finishLaunching) && Storage.shared.isValid && !hasInitialRootViewController else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.hasInitialRootViewController = true
|
/// Start a timeout for the creation of the rootViewController setup process (if it takes too long then we want to give the user
|
||||||
self.window?.rootViewController = TopBannerController(
|
/// the option to export their logs)
|
||||||
child: StyledNavigationController(
|
let populateHomeScreenTimer: Timer = Timer.scheduledTimerOnMainThread(
|
||||||
rootViewController: {
|
withTimeInterval: AppDelegate.maxRootViewControllerInitialQueryDuration,
|
||||||
guard Identity.userExists() else { return LandingVC() }
|
repeats: false
|
||||||
guard !Profile.fetchOrCreateCurrentUser().name.isEmpty else {
|
) { [weak self] timer in
|
||||||
// If we have no display name then collect one (this can happen if the
|
timer.invalidate()
|
||||||
// app crashed during onboarding which would leave the user in an invalid
|
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout)
|
||||||
// state with no display name)
|
}
|
||||||
return DisplayNameVC(flow: .register)
|
|
||||||
}
|
|
||||||
|
|
||||||
return HomeVC()
|
|
||||||
}()
|
|
||||||
),
|
|
||||||
cachedWarning: UserDefaults.sharedLokiProject?[.topBannerWarningToShow]
|
|
||||||
.map { rawValue in TopBannerController.Warning(rawValue: rawValue) }
|
|
||||||
)
|
|
||||||
UIViewController.attemptRotationToDeviceOrientation()
|
|
||||||
|
|
||||||
/// **Note:** There is an annoying case when starting the app by interacting with a push notification where
|
// All logic which needs to run after the 'rootViewController' is created
|
||||||
/// the `HomeVC` won't have completed loading it's view which means the `SessionApp.homeViewController`
|
let rootViewControllerSetupComplete: (UIViewController) -> () = { [weak self] rootViewController in
|
||||||
/// won't have been set - we set the value directly here to resolve this edge case
|
let presentedViewController: UIViewController? = self?.window?.rootViewController?.presentedViewController
|
||||||
if let homeViewController: HomeVC = (self.window?.rootViewController as? UINavigationController)?.viewControllers.first as? HomeVC {
|
let targetRootViewController: UIViewController = TopBannerController(
|
||||||
SessionApp.homeViewController.mutate { $0 = homeViewController }
|
child: StyledNavigationController(rootViewController: rootViewController),
|
||||||
|
cachedWarning: UserDefaults.sharedLokiProject?[.topBannerWarningToShow]
|
||||||
|
.map { rawValue in TopBannerController.Warning(rawValue: rawValue) }
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Insert the `targetRootViewController` below the current view and trigger a layout without animation before properly
|
||||||
|
/// swapping the `rootViewController` over so we can avoid any weird initial layout behaviours
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
self?.window?.rootViewController = targetRootViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.hasInitialRootViewController = true
|
||||||
|
UIViewController.attemptRotationToDeviceOrientation()
|
||||||
|
|
||||||
|
/// **Note:** There is an annoying case when starting the app by interacting with a push notification where
|
||||||
|
/// the `HomeVC` won't have completed loading it's view which means the `SessionApp.homeViewController`
|
||||||
|
/// won't have been set - we set the value directly here to resolve this edge case
|
||||||
|
if let homeViewController: HomeVC = rootViewController as? HomeVC {
|
||||||
|
SessionApp.homeViewController.mutate { $0 = homeViewController }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If we were previously presenting a viewController but are no longer preseting it then present it again
|
||||||
|
///
|
||||||
|
/// **Note:** Looks like the OS will throw an exception if we try to present a screen which is already (or
|
||||||
|
/// was previously?) presented, even if it's not attached to the screen it seems...
|
||||||
|
switch presentedViewController {
|
||||||
|
case is UIAlertController, is ConfirmationModal:
|
||||||
|
/// If the viewController we were presenting happened to be the "failed startup" modal then we can dismiss it
|
||||||
|
/// automatically (while this seems redundant it's less jarring for the user than just instantly having it disappear)
|
||||||
|
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout, animated: false) {
|
||||||
|
self?.window?.rootViewController?.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
case is UIActivityViewController: HelpViewModel.shareLogs(animated: false)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup is completed so run any post-setup tasks
|
||||||
|
onComplete?()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the approriate screen depending on the onboarding state
|
||||||
|
switch Onboarding.State.current {
|
||||||
|
case .newUser:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let viewController: LandingVC = LandingVC()
|
||||||
|
populateHomeScreenTimer.invalidate()
|
||||||
|
rootViewControllerSetupComplete(viewController)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .missingName:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let viewController: DisplayNameVC = DisplayNameVC(flow: .register)
|
||||||
|
populateHomeScreenTimer.invalidate()
|
||||||
|
rootViewControllerSetupComplete(viewController)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .completed:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let viewController: HomeVC = HomeVC()
|
||||||
|
|
||||||
|
/// We want to start observing the changes for the 'HomeVC' and want to wait until we actually get data back before we
|
||||||
|
/// continue as we don't want to show a blank home screen
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
viewController.startObservingChanges() {
|
||||||
|
populateHomeScreenTimer.invalidate()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
rootViewControllerSetupComplete(viewController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -750,3 +844,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - LifecycleMethod
|
||||||
|
|
||||||
|
private enum LifecycleMethod {
|
||||||
|
case finishLaunching
|
||||||
|
case enterForeground
|
||||||
|
case didBecomeActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - StartupError
|
||||||
|
|
||||||
|
private enum StartupError: Error {
|
||||||
|
case databaseError(Error)
|
||||||
|
case failedToRestore
|
||||||
|
case startupTimeout
|
||||||
|
|
||||||
|
var message: String {
|
||||||
|
switch self {
|
||||||
|
case .databaseError(StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized()
|
||||||
|
case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized()
|
||||||
|
case .databaseError: return "DATABASE_MIGRATION_FAILED".localized()
|
||||||
|
case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,7 +6,31 @@ import SessionMessagingKit
|
||||||
import SignalCoreKit
|
import SignalCoreKit
|
||||||
|
|
||||||
public struct SessionApp {
|
public struct SessionApp {
|
||||||
|
// FIXME: Refactor this to be protocol based for unit testing (or even dynamic based on view hierarchy - do want to avoid needing to use the main thread to access them though)
|
||||||
static let homeViewController: Atomic<HomeVC?> = Atomic(nil)
|
static let homeViewController: Atomic<HomeVC?> = Atomic(nil)
|
||||||
|
static let currentlyOpenConversationViewController: Atomic<ConversationVC?> = Atomic(nil)
|
||||||
|
|
||||||
|
static var versionInfo: String {
|
||||||
|
let buildNumber: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String)
|
||||||
|
.map { " (\($0))" }
|
||||||
|
.defaulting(to: "")
|
||||||
|
let appVersion: String? = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)
|
||||||
|
.map { "App: \($0)\(buildNumber)" }
|
||||||
|
#if DEBUG
|
||||||
|
let commitInfo: String? = (Bundle.main.infoDictionary?["GitCommitHash"] as? String).map { "Commit: \($0)" }
|
||||||
|
#else
|
||||||
|
let commitInfo: String? = nil
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let versionInfo: [String] = [
|
||||||
|
"iOS \(UIDevice.current.systemVersion)",
|
||||||
|
appVersion,
|
||||||
|
"libSession: \(SessionUtil.libSessionVersion)",
|
||||||
|
commitInfo
|
||||||
|
].compactMap { $0 }
|
||||||
|
|
||||||
|
return versionInfo.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - View Convenience Methods
|
// MARK: - View Convenience Methods
|
||||||
|
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "متاسفانه خطایی رخ داده است";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "متاسفانه خطایی رخ داده است";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید";
|
||||||
"LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها...";
|
"LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "هنگام بهینهسازی پایگاه داده خطایی روی داد\n\nشما میتوانید گزارشهای برنامه خود را صادر کنید تا بتوانید برای عیبیابی به اشتراک بگذارید یا میتوانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن دادههای قدیمیتر از دو هفته میشود.";
|
"DATABASE_MIGRATION_FAILED" = "هنگام بهینهسازی پایگاه داده خطایی روی داد\n\nشما میتوانید گزارشهای برنامه خود را صادر کنید تا بتوانید برای عیبیابی به اشتراک بگذارید یا میتوانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن دادههای قدیمیتر از دو هفته میشود.";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oups, une erreur est survenue";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oups, une erreur est survenue";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard";
|
||||||
"LOADING_CONVERSATIONS" = "Chargement des conversations...";
|
"LOADING_CONVERSATIONS" = "Chargement des conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines";
|
"DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -414,8 +414,10 @@
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
|
||||||
|
"APP_STARTUP_EXIT" = "Exit";
|
||||||
|
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
|
||||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||||
|
|
|
@ -99,6 +99,7 @@ protocol NotificationPresenterAdaptee: AnyObject {
|
||||||
sound: Preferences.Sound?,
|
sound: Preferences.Sound?,
|
||||||
threadVariant: SessionThread.Variant,
|
threadVariant: SessionThread.Variant,
|
||||||
threadName: String,
|
threadName: String,
|
||||||
|
applicationState: UIApplication.State,
|
||||||
replacingIdentifier: String?
|
replacingIdentifier: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -116,7 +117,8 @@ extension NotificationPresenterAdaptee {
|
||||||
previewType: Preferences.NotificationPreviewType,
|
previewType: Preferences.NotificationPreviewType,
|
||||||
sound: Preferences.Sound?,
|
sound: Preferences.Sound?,
|
||||||
threadVariant: SessionThread.Variant,
|
threadVariant: SessionThread.Variant,
|
||||||
threadName: String
|
threadName: String,
|
||||||
|
applicationState: UIApplication.State
|
||||||
) {
|
) {
|
||||||
notify(
|
notify(
|
||||||
category: category,
|
category: category,
|
||||||
|
@ -127,22 +129,16 @@ extension NotificationPresenterAdaptee {
|
||||||
sound: sound,
|
sound: sound,
|
||||||
threadVariant: threadVariant,
|
threadVariant: threadVariant,
|
||||||
threadName: threadName,
|
threadName: threadName,
|
||||||
|
applicationState: applicationState,
|
||||||
replacingIdentifier: nil
|
replacingIdentifier: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc(OWSNotificationPresenter)
|
public class NotificationPresenter: NotificationsProtocol {
|
||||||
public class NotificationPresenter: NSObject, NotificationsProtocol {
|
private let adaptee: NotificationPresenterAdaptee = UserNotificationPresenterAdaptee()
|
||||||
|
|
||||||
private let adaptee: NotificationPresenterAdaptee
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public override init() {
|
|
||||||
self.adaptee = UserNotificationPresenterAdaptee()
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
|
public init() {
|
||||||
SwiftSingletons.register(self)
|
SwiftSingletons.register(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,7 +148,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
return adaptee.registerNotificationSettings()
|
return adaptee.registerNotificationSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) {
|
public func notifyUser(
|
||||||
|
_ db: Database,
|
||||||
|
for interaction: Interaction,
|
||||||
|
in thread: SessionThread,
|
||||||
|
applicationState: UIApplication.State
|
||||||
|
) {
|
||||||
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
||||||
|
|
||||||
// Ensure we should be showing a notification for the thread
|
// Ensure we should be showing a notification for the thread
|
||||||
|
@ -244,34 +245,39 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
let sound: Preferences.Sound? = requestSound(
|
||||||
let sound: Preferences.Sound? = self.requestSound(
|
thread: thread,
|
||||||
thread: thread,
|
fallbackSound: fallbackSound,
|
||||||
fallbackSound: fallbackSound
|
applicationState: applicationState
|
||||||
)
|
)
|
||||||
|
|
||||||
notificationBody = MentionUtilities.highlightMentionsNoAttributes(
|
notificationBody = MentionUtilities.highlightMentionsNoAttributes(
|
||||||
in: (notificationBody ?? ""),
|
in: (notificationBody ?? ""),
|
||||||
threadVariant: thread.variant,
|
threadVariant: thread.variant,
|
||||||
currentUserPublicKey: userPublicKey,
|
currentUserPublicKey: userPublicKey,
|
||||||
currentUserBlindedPublicKey: userBlindedKey
|
currentUserBlindedPublicKey: userBlindedKey
|
||||||
)
|
)
|
||||||
|
|
||||||
self.adaptee.notify(
|
self.adaptee.notify(
|
||||||
category: category,
|
category: category,
|
||||||
title: notificationTitle,
|
title: notificationTitle,
|
||||||
body: (notificationBody ?? ""),
|
body: (notificationBody ?? ""),
|
||||||
userInfo: userInfo,
|
userInfo: userInfo,
|
||||||
previewType: previewType,
|
previewType: previewType,
|
||||||
sound: sound,
|
sound: sound,
|
||||||
threadVariant: thread.variant,
|
threadVariant: thread.variant,
|
||||||
threadName: groupName,
|
threadName: groupName,
|
||||||
replacingIdentifier: identifier
|
applicationState: applicationState,
|
||||||
)
|
replacingIdentifier: identifier
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) {
|
public func notifyUser(
|
||||||
|
_ db: Database,
|
||||||
|
forIncomingCall interaction: Interaction,
|
||||||
|
in thread: SessionThread,
|
||||||
|
applicationState: UIApplication.State
|
||||||
|
) {
|
||||||
// No call notifications for muted or group threads
|
// No call notifications for muted or group threads
|
||||||
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
||||||
guard
|
guard
|
||||||
|
@ -320,28 +326,32 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
|
|
||||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||||
|
let sound = self.requestSound(
|
||||||
|
thread: thread,
|
||||||
|
fallbackSound: fallbackSound,
|
||||||
|
applicationState: applicationState
|
||||||
|
)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
self.adaptee.notify(
|
||||||
let sound = self.requestSound(
|
category: category,
|
||||||
thread: thread,
|
title: notificationTitle,
|
||||||
fallbackSound: fallbackSound
|
body: (notificationBody ?? ""),
|
||||||
)
|
userInfo: userInfo,
|
||||||
|
previewType: previewType,
|
||||||
self.adaptee.notify(
|
sound: sound,
|
||||||
category: category,
|
threadVariant: thread.variant,
|
||||||
title: notificationTitle,
|
threadName: senderName,
|
||||||
body: (notificationBody ?? ""),
|
applicationState: applicationState,
|
||||||
userInfo: userInfo,
|
replacingIdentifier: UUID().uuidString
|
||||||
previewType: previewType,
|
)
|
||||||
sound: sound,
|
|
||||||
threadVariant: thread.variant,
|
|
||||||
threadName: senderName,
|
|
||||||
replacingIdentifier: UUID().uuidString
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) {
|
public func notifyUser(
|
||||||
|
_ db: Database,
|
||||||
|
forReaction reaction: Reaction,
|
||||||
|
in thread: SessionThread,
|
||||||
|
applicationState: UIApplication.State
|
||||||
|
) {
|
||||||
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
||||||
|
|
||||||
// No reaction notifications for muted, group threads or message requests
|
// No reaction notifications for muted, group threads or message requests
|
||||||
|
@ -380,28 +390,31 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
)
|
)
|
||||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||||
|
let sound = self.requestSound(
|
||||||
DispatchQueue.main.async {
|
thread: thread,
|
||||||
let sound = self.requestSound(
|
fallbackSound: fallbackSound,
|
||||||
thread: thread,
|
applicationState: applicationState
|
||||||
fallbackSound: fallbackSound
|
)
|
||||||
)
|
|
||||||
|
self.adaptee.notify(
|
||||||
self.adaptee.notify(
|
category: category,
|
||||||
category: category,
|
title: notificationTitle,
|
||||||
title: notificationTitle,
|
body: notificationBody,
|
||||||
body: notificationBody,
|
userInfo: userInfo,
|
||||||
userInfo: userInfo,
|
previewType: previewType,
|
||||||
previewType: previewType,
|
sound: sound,
|
||||||
sound: sound,
|
threadVariant: thread.variant,
|
||||||
threadVariant: thread.variant,
|
threadName: threadName,
|
||||||
threadName: threadName,
|
applicationState: applicationState,
|
||||||
replacingIdentifier: UUID().uuidString
|
replacingIdentifier: UUID().uuidString
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func notifyForFailedSend(_ db: Database, in thread: SessionThread) {
|
public func notifyForFailedSend(
|
||||||
|
_ db: Database,
|
||||||
|
in thread: SessionThread,
|
||||||
|
applicationState: UIApplication.State
|
||||||
|
) {
|
||||||
let notificationTitle: String?
|
let notificationTitle: String?
|
||||||
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
||||||
.defaulting(to: .defaultPreviewType)
|
.defaulting(to: .defaultPreviewType)
|
||||||
|
@ -432,24 +445,23 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
]
|
]
|
||||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||||
|
let sound: Preferences.Sound? = self.requestSound(
|
||||||
DispatchQueue.main.async {
|
thread: thread,
|
||||||
let sound: Preferences.Sound? = self.requestSound(
|
fallbackSound: fallbackSound,
|
||||||
thread: thread,
|
applicationState: applicationState
|
||||||
fallbackSound: fallbackSound
|
)
|
||||||
)
|
|
||||||
|
self.adaptee.notify(
|
||||||
self.adaptee.notify(
|
category: .errorMessage,
|
||||||
category: .errorMessage,
|
title: notificationTitle,
|
||||||
title: notificationTitle,
|
body: notificationBody,
|
||||||
body: notificationBody,
|
userInfo: userInfo,
|
||||||
userInfo: userInfo,
|
previewType: previewType,
|
||||||
previewType: previewType,
|
sound: sound,
|
||||||
sound: sound,
|
threadVariant: thread.variant,
|
||||||
threadVariant: thread.variant,
|
threadName: threadName,
|
||||||
threadName: threadName
|
applicationState: applicationState
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
|
@ -471,32 +483,30 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
var mostRecentNotifications = TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)
|
var mostRecentNotifications: Atomic<TruncatedList<UInt64>> = Atomic(TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount))
|
||||||
|
|
||||||
private func requestSound(thread: SessionThread, fallbackSound: Preferences.Sound) -> Preferences.Sound? {
|
private func requestSound(
|
||||||
guard checkIfShouldPlaySound() else {
|
thread: SessionThread,
|
||||||
return nil
|
fallbackSound: Preferences.Sound,
|
||||||
}
|
applicationState: UIApplication.State
|
||||||
|
) -> Preferences.Sound? {
|
||||||
|
guard checkIfShouldPlaySound(applicationState: applicationState) else { return nil }
|
||||||
|
|
||||||
return (thread.notificationSound ?? fallbackSound)
|
return (thread.notificationSound ?? fallbackSound)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkIfShouldPlaySound() -> Bool {
|
private func checkIfShouldPlaySound(applicationState: UIApplication.State) -> Bool {
|
||||||
AssertIsOnMainThread()
|
guard applicationState == .active else { return true }
|
||||||
|
|
||||||
guard UIApplication.shared.applicationState == .active else { return true }
|
|
||||||
guard Storage.shared[.playNotificationSoundInForeground] else { return false }
|
guard Storage.shared[.playNotificationSoundInForeground] else { return false }
|
||||||
|
|
||||||
let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000))
|
let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000))
|
||||||
let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs))
|
let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs))
|
||||||
|
|
||||||
let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold }
|
let recentNotifications = mostRecentNotifications.wrappedValue.filter { $0 > recentThreshold }
|
||||||
|
|
||||||
guard recentNotifications.count < kAudioNotificationsThrottleCount else {
|
guard recentNotifications.count < kAudioNotificationsThrottleCount else { return false }
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
mostRecentNotifications.append(nowMs)
|
mostRecentNotifications.mutate { $0.append(nowMs) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -527,7 +537,11 @@ class NotificationActionHandler {
|
||||||
return markAsRead(threadId: threadId)
|
return markAsRead(threadId: threadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func reply(userInfo: [AnyHashable: Any], replyText: String) -> AnyPublisher<Void, Error> {
|
func reply(
|
||||||
|
userInfo: [AnyHashable: Any],
|
||||||
|
replyText: String,
|
||||||
|
applicationState: UIApplication.State
|
||||||
|
) -> AnyPublisher<Void, Error> {
|
||||||
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
||||||
return Fail<Void, Error>(error: NotificationError.failDebug("threadId was unexpectedly nil"))
|
return Fail<Void, Error>(error: NotificationError.failDebug("threadId was unexpectedly nil"))
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -576,7 +590,11 @@ class NotificationActionHandler {
|
||||||
case .finished: break
|
case .finished: break
|
||||||
case .failure:
|
case .failure:
|
||||||
Storage.shared.read { [weak self] db in
|
Storage.shared.read { [weak self] db in
|
||||||
self?.notificationPresenter.notifyForFailedSend(db, in: thread)
|
self?.notificationPresenter.notifyForFailedSend(
|
||||||
|
db,
|
||||||
|
in: thread,
|
||||||
|
applicationState: applicationState
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,7 +61,7 @@ class UserNotificationConfig {
|
||||||
|
|
||||||
class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelegate {
|
class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelegate {
|
||||||
private let notificationCenter: UNUserNotificationCenter
|
private let notificationCenter: UNUserNotificationCenter
|
||||||
private var notifications: [String: UNNotificationRequest] = [:]
|
private var notifications: Atomic<[String: UNNotificationRequest]> = Atomic([:])
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
self.notificationCenter = UNUserNotificationCenter.current()
|
self.notificationCenter = UNUserNotificationCenter.current()
|
||||||
|
@ -105,10 +105,9 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
||||||
sound: Preferences.Sound?,
|
sound: Preferences.Sound?,
|
||||||
threadVariant: SessionThread.Variant,
|
threadVariant: SessionThread.Variant,
|
||||||
threadName: String,
|
threadName: String,
|
||||||
|
applicationState: UIApplication.State,
|
||||||
replacingIdentifier: String?
|
replacingIdentifier: String?
|
||||||
) {
|
) {
|
||||||
AssertIsOnMainThread()
|
|
||||||
|
|
||||||
let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String)
|
let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String)
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.categoryIdentifier = category.identifier
|
content.categoryIdentifier = category.identifier
|
||||||
|
@ -119,16 +118,21 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
||||||
threadVariant == .community &&
|
threadVariant == .community &&
|
||||||
replacingIdentifier == threadIdentifier
|
replacingIdentifier == threadIdentifier
|
||||||
)
|
)
|
||||||
let isAppActive = UIApplication.shared.applicationState == .active
|
|
||||||
if let sound = sound, sound != .none {
|
if let sound = sound, sound != .none {
|
||||||
content.sound = sound.notificationSound(isQuiet: isAppActive)
|
content.sound = sound.notificationSound(isQuiet: (applicationState == .active))
|
||||||
}
|
}
|
||||||
|
|
||||||
let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString)
|
let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString)
|
||||||
let isReplacingNotification: Bool = (notifications[notificationIdentifier] != nil)
|
let isReplacingNotification: Bool = (notifications.wrappedValue[notificationIdentifier] != nil)
|
||||||
|
let shouldPresentNotification: Bool = shouldPresentNotification(
|
||||||
|
category: category,
|
||||||
|
applicationState: applicationState,
|
||||||
|
frontMostViewController: SessionApp.currentlyOpenConversationViewController.wrappedValue,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
var trigger: UNNotificationTrigger?
|
var trigger: UNNotificationTrigger?
|
||||||
|
|
||||||
if shouldPresentNotification(category: category, userInfo: userInfo) {
|
if shouldPresentNotification {
|
||||||
if let displayableTitle = title?.filterForDisplay {
|
if let displayableTitle = title?.filterForDisplay {
|
||||||
content.title = displayableTitle
|
content.title = displayableTitle
|
||||||
}
|
}
|
||||||
|
@ -142,7 +146,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
||||||
repeats: false
|
repeats: false
|
||||||
)
|
)
|
||||||
|
|
||||||
let numberExistingNotifications: Int? = notifications[notificationIdentifier]?
|
let numberExistingNotifications: Int? = notifications.wrappedValue[notificationIdentifier]?
|
||||||
.content
|
.content
|
||||||
.userInfo[AppNotificationUserInfoKey.threadNotificationCounter]
|
.userInfo[AppNotificationUserInfoKey.threadNotificationCounter]
|
||||||
.asType(Int.self)
|
.asType(Int.self)
|
||||||
|
@ -180,47 +184,48 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
||||||
if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) }
|
if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) }
|
||||||
|
|
||||||
notificationCenter.add(request)
|
notificationCenter.add(request)
|
||||||
notifications[notificationIdentifier] = request
|
notifications.mutate { $0[notificationIdentifier] = request }
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelNotifications(identifiers: [String]) {
|
func cancelNotifications(identifiers: [String]) {
|
||||||
AssertIsOnMainThread()
|
notifications.mutate { notifications in
|
||||||
identifiers.forEach { notifications.removeValue(forKey: $0) }
|
identifiers.forEach { notifications.removeValue(forKey: $0) }
|
||||||
|
}
|
||||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers)
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelNotification(_ notification: UNNotificationRequest) {
|
func cancelNotification(_ notification: UNNotificationRequest) {
|
||||||
AssertIsOnMainThread()
|
|
||||||
cancelNotifications(identifiers: [notification.identifier])
|
cancelNotifications(identifiers: [notification.identifier])
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelNotifications(threadId: String) {
|
func cancelNotifications(threadId: String) {
|
||||||
AssertIsOnMainThread()
|
let notificationsIdsToCancel: [String] = notifications.wrappedValue
|
||||||
for notification in notifications.values {
|
.values
|
||||||
guard let notificationThreadId = notification.content.userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
.compactMap { notification in
|
||||||
continue
|
guard
|
||||||
|
let notificationThreadId: String = notification.content.userInfo[AppNotificationUserInfoKey.threadId] as? String,
|
||||||
|
notificationThreadId == threadId
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return notification.identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
guard notificationThreadId == threadId else {
|
cancelNotifications(identifiers: notificationsIdsToCancel)
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelNotification(notification)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearAllNotifications() {
|
func clearAllNotifications() {
|
||||||
AssertIsOnMainThread()
|
|
||||||
notificationCenter.removeAllPendingNotificationRequests()
|
notificationCenter.removeAllPendingNotificationRequests()
|
||||||
notificationCenter.removeAllDeliveredNotifications()
|
notificationCenter.removeAllDeliveredNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldPresentNotification(category: AppNotificationCategory, userInfo: [AnyHashable: Any]) -> Bool {
|
func shouldPresentNotification(
|
||||||
AssertIsOnMainThread()
|
category: AppNotificationCategory,
|
||||||
guard UIApplication.shared.applicationState == .active else {
|
applicationState: UIApplication.State,
|
||||||
return true
|
frontMostViewController: UIViewController?,
|
||||||
}
|
userInfo: [AnyHashable: Any]
|
||||||
|
) -> Bool {
|
||||||
|
guard applicationState == .active else { return true }
|
||||||
|
|
||||||
guard category == .incomingMessage || category == .errorMessage else {
|
guard category == .incomingMessage || category == .errorMessage else {
|
||||||
return true
|
return true
|
||||||
|
@ -231,7 +236,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else {
|
guard let conversationViewController: ConversationVC = frontMostViewController as? ConversationVC else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -271,7 +276,8 @@ public class UserNotificationActionHandler: NSObject {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
assert(AppReadiness.isAppReady())
|
assert(AppReadiness.isAppReady())
|
||||||
|
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo
|
||||||
|
let applicationState: UIApplication.State = UIApplication.shared.applicationState
|
||||||
|
|
||||||
switch response.actionIdentifier {
|
switch response.actionIdentifier {
|
||||||
case UNNotificationDefaultActionIdentifier:
|
case UNNotificationDefaultActionIdentifier:
|
||||||
|
@ -307,7 +313,7 @@ public class UserNotificationActionHandler: NSObject {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
return actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText)
|
return actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText, applicationState: applicationState)
|
||||||
|
|
||||||
case .showThread:
|
case .showThread:
|
||||||
return actionHandler.showThread(userInfo: userInfo)
|
return actionHandler.showThread(userInfo: userInfo)
|
||||||
|
|
|
@ -123,6 +123,25 @@ enum Onboarding {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
case newUser
|
||||||
|
case missingName
|
||||||
|
case completed
|
||||||
|
|
||||||
|
static var current: State {
|
||||||
|
// If we have no identify information then the user needs to register
|
||||||
|
guard Identity.userExists() else { return .newUser }
|
||||||
|
|
||||||
|
// If we have no display name then collect one (this can happen if the
|
||||||
|
// app crashed during onboarding which would leave the user in an invalid
|
||||||
|
// state with no display name)
|
||||||
|
guard !Profile.fetchOrCreateCurrentUser().name.isEmpty else { return .missingName }
|
||||||
|
|
||||||
|
// Otherwise we have enough for a full user and can start the app
|
||||||
|
return .completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Flow {
|
enum Flow {
|
||||||
case register, recover, link
|
case register, recover, link
|
||||||
|
|
||||||
|
|
|
@ -169,7 +169,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC
|
||||||
|
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in
|
||||||
Storage.shared
|
Storage.shared
|
||||||
.writePublisherFlatMap { db in
|
.writePublisher { db in
|
||||||
OpenGroupManager.shared.add(
|
OpenGroupManager.shared.add(
|
||||||
db,
|
db,
|
||||||
roomToken: roomToken,
|
roomToken: roomToken,
|
||||||
|
@ -178,17 +178,40 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC
|
||||||
calledFromConfigHandling: false
|
calledFromConfigHandling: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.flatMap { successfullyAddedGroup in
|
||||||
|
OpenGroupManager.shared.performInitialRequestsAfterAdd(
|
||||||
|
successfullyAddedGroup: successfullyAddedGroup,
|
||||||
|
roomToken: roomToken,
|
||||||
|
server: server,
|
||||||
|
publicKey: publicKey,
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
|
}
|
||||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveCompletion: { result in
|
receiveCompletion: { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
// If there was a failure then the group will be in invalid state until
|
||||||
let title = "COMMUNITY_ERROR_GENERIC".localized()
|
// the next launch so remove it (the user will be left on the previous
|
||||||
let message = error.localizedDescription
|
// screen so can re-trigger the join)
|
||||||
|
Storage.shared.writeAsync { db in
|
||||||
|
OpenGroupManager.shared.delete(
|
||||||
|
db,
|
||||||
|
openGroupId: OpenGroup.idFor(roomToken: roomToken, server: server),
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the user an error indicating they failed to properly join the group
|
||||||
self?.isJoining = false
|
self?.isJoining = false
|
||||||
self?.showError(title: title, message: message)
|
self?.dismiss(animated: true) { // Dismiss the loader
|
||||||
|
self?.showError(
|
||||||
|
title: "COMMUNITY_ERROR_GENERIC".localized(),
|
||||||
|
message: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case .finished:
|
case .finished:
|
||||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
|
|
|
@ -9,7 +9,7 @@ import SessionUIKit
|
||||||
final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||||
private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2)
|
private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2)
|
||||||
private var maxWidth: CGFloat
|
private var maxWidth: CGFloat
|
||||||
private var rooms: [OpenGroupAPI.Room] = [] { didSet { update() } }
|
private var data: [OpenGroupManager.DefaultRoomInfo] = [] { didSet { update() } }
|
||||||
private var heightConstraint: NSLayoutConstraint!
|
private var heightConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
var delegate: OpenGroupSuggestionGridDelegate?
|
var delegate: OpenGroupSuggestionGridDelegate?
|
||||||
|
@ -144,10 +144,15 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
|
||||||
|
|
||||||
OpenGroupManager.getDefaultRoomsIfNeeded()
|
OpenGroupManager.getDefaultRoomsIfNeeded()
|
||||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: DispatchQueue.main)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveCompletion: { [weak self] _ in self?.update() },
|
receiveCompletion: { [weak self] result in
|
||||||
receiveValue: { [weak self] rooms in self?.rooms = rooms }
|
switch result {
|
||||||
|
case .finished: break
|
||||||
|
case .failure: self?.update()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiveValue: { [weak self] roomInfo in self?.data = roomInfo }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,7 +162,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
|
||||||
spinner.stopAnimating()
|
spinner.stopAnimating()
|
||||||
spinner.isHidden = true
|
spinner.isHidden = true
|
||||||
|
|
||||||
let roomCount: CGFloat = CGFloat(min(rooms.count, 8)) // Cap to a maximum of 8 (4 rows of 2)
|
let roomCount: CGFloat = CGFloat(min(data.count, 8)) // Cap to a maximum of 8 (4 rows of 2)
|
||||||
let numRows: CGFloat = ceil(roomCount / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells))
|
let numRows: CGFloat = ceil(roomCount / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells))
|
||||||
let height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing))
|
let height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing))
|
||||||
heightConstraint.constant = height
|
heightConstraint.constant = height
|
||||||
|
@ -184,7 +189,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
|
||||||
|
|
||||||
// If there isn't an even number of items then we want to calculate proper sizing
|
// If there isn't an even number of items then we want to calculate proper sizing
|
||||||
return CGSize(
|
return CGSize(
|
||||||
width: Cell.calculatedWith(for: rooms[indexPath.item].name),
|
width: Cell.calculatedWith(for: data[indexPath.item].room.name),
|
||||||
height: OpenGroupSuggestionGrid.cellHeight
|
height: OpenGroupSuggestionGrid.cellHeight
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -192,12 +197,12 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
|
||||||
// MARK: - Data Source
|
// MARK: - Data Source
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
return min(rooms.count, 8) // Cap to a maximum of 8 (4 rows of 2)
|
return min(data.count, 8) // Cap to a maximum of 8 (4 rows of 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath)
|
let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath)
|
||||||
cell.room = rooms[indexPath.item]
|
cell.update(with: data[indexPath.item].room, existingImageData: data[indexPath.item].existingImageData)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
@ -205,7 +210,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
let room = rooms[indexPath.section * itemsPerSection + indexPath.item]
|
let room = data[indexPath.section * itemsPerSection + indexPath.item].room
|
||||||
delegate?.join(room)
|
delegate?.join(room)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -232,8 +237,6 @@ extension OpenGroupSuggestionGrid {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var room: OpenGroupAPI.Room? { didSet { update() } }
|
|
||||||
|
|
||||||
private lazy var snContentView: UIView = {
|
private lazy var snContentView: UIView = {
|
||||||
let result: UIView = UIView()
|
let result: UIView = UIView()
|
||||||
result.themeBorderColor = .borderSeparator
|
result.themeBorderColor = .borderSeparator
|
||||||
|
@ -307,9 +310,7 @@ extension OpenGroupSuggestionGrid {
|
||||||
snContentView.pin(to: self)
|
snContentView.pin(to: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func update() {
|
fileprivate func update(with room: OpenGroupAPI.Room, existingImageData: Data?) {
|
||||||
guard let room: OpenGroupAPI.Room = room else { return }
|
|
||||||
|
|
||||||
label.text = room.name
|
label.text = room.name
|
||||||
|
|
||||||
// Only continue if we have a room image
|
// Only continue if we have a room image
|
||||||
|
@ -322,11 +323,13 @@ extension OpenGroupSuggestionGrid {
|
||||||
|
|
||||||
Publishers
|
Publishers
|
||||||
.MergeMany(
|
.MergeMany(
|
||||||
Storage.shared
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { db in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: imageId,
|
||||||
.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer)
|
for: room.token,
|
||||||
}
|
on: OpenGroupAPI.defaultServer,
|
||||||
|
existingData: existingImageData
|
||||||
|
)
|
||||||
.map { ($0, true) }
|
.map { ($0, true) }
|
||||||
.eraseToAnyPublisher(),
|
.eraseToAnyPublisher(),
|
||||||
// If we have already received the room image then the above will emit first and
|
// If we have already received the room image then the above will emit first and
|
||||||
|
@ -337,7 +340,7 @@ extension OpenGroupSuggestionGrid {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
)
|
)
|
||||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: DispatchQueue.main)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveValue: { [weak self] imageData, hasData in
|
receiveValue: { [weak self] imageData, hasData in
|
||||||
guard hasData else {
|
guard hasData else {
|
||||||
|
|
|
@ -177,23 +177,10 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
||||||
public static func shareLogs(
|
public static func shareLogs(
|
||||||
viewControllerToDismiss: UIViewController? = nil,
|
viewControllerToDismiss: UIViewController? = nil,
|
||||||
targetView: UIView? = nil,
|
targetView: UIView? = nil,
|
||||||
|
animated: Bool = true,
|
||||||
onShareComplete: (() -> ())? = nil
|
onShareComplete: (() -> ())? = nil
|
||||||
) {
|
) {
|
||||||
let version: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)
|
OWSLogger.info("[Version] \(SessionApp.versionInfo)")
|
||||||
.defaulting(to: "")
|
|
||||||
#if DEBUG
|
|
||||||
let commitInfo: String? = (Bundle.main.infoDictionary?["GitCommitHash"] as? String).map { "Commit: \($0)" }
|
|
||||||
#else
|
|
||||||
let commitInfo: String? = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let versionInfo: [String] = [
|
|
||||||
"iOS \(UIDevice.current.systemVersion)",
|
|
||||||
"App: \(version)",
|
|
||||||
"libSession: \(SessionUtil.libSessionVersion)",
|
|
||||||
commitInfo
|
|
||||||
].compactMap { $0 }
|
|
||||||
OWSLogger.info("[Version] \(versionInfo.joined(separator: ", "))")
|
|
||||||
DDLog.flushLog()
|
DDLog.flushLog()
|
||||||
|
|
||||||
let logFilePaths: [String] = AppEnvironment.shared.fileLogger.logFileManager.sortedLogFilePaths
|
let logFilePaths: [String] = AppEnvironment.shared.fileLogger.logFileManager.sortedLogFilePaths
|
||||||
|
@ -216,7 +203,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
||||||
shareVC.popoverPresentationController?.sourceView = (targetView ?? viewController.view)
|
shareVC.popoverPresentationController?.sourceView = (targetView ?? viewController.view)
|
||||||
shareVC.popoverPresentationController?.sourceRect = (targetView ?? viewController.view).bounds
|
shareVC.popoverPresentationController?.sourceRect = (targetView ?? viewController.view).bounds
|
||||||
}
|
}
|
||||||
viewController.present(shareVC, animated: true, completion: nil)
|
viewController.present(shareVC, animated: animated, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let viewControllerToDismiss: UIViewController = viewControllerToDismiss else {
|
guard let viewControllerToDismiss: UIViewController = viewControllerToDismiss else {
|
||||||
|
@ -224,7 +211,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewControllerToDismiss.dismiss(animated: true) {
|
viewControllerToDismiss.dismiss(animated: animated) {
|
||||||
showShareSheet()
|
showShareSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -249,7 +249,7 @@ final class NukeDataModal: Modal {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteAllLocalData() {
|
private func deleteAllLocalData(using dependencies: Dependencies = Dependencies()) {
|
||||||
// Unregister push notifications if needed
|
// Unregister push notifications if needed
|
||||||
let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs]
|
let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs]
|
||||||
let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken]
|
let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken]
|
||||||
|
@ -273,7 +273,10 @@ final class NukeDataModal: Modal {
|
||||||
UserDefaults.removeAll()
|
UserDefaults.removeAll()
|
||||||
|
|
||||||
// Remove the cached key so it gets re-cached on next access
|
// Remove the cached key so it gets re-cached on next access
|
||||||
General.cache.mutate { $0.encodedPublicKey = nil }
|
dependencies.mutableGeneralCache.mutate {
|
||||||
|
$0.encodedPublicKey = nil
|
||||||
|
$0.recentReactionTimestamps = []
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the Snode pool
|
// Clear the Snode pool
|
||||||
SnodeAPI.clearSnodePool()
|
SnodeAPI.clearSnodePool()
|
||||||
|
|
|
@ -187,13 +187,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
private func startObservingChanges() {
|
private func startObservingChanges() {
|
||||||
// Start observing for data changes
|
// Start observing for data changes
|
||||||
dataChangeCancellable = viewModel.observableTableData
|
dataChangeCancellable = viewModel.observableTableData
|
||||||
.receive(
|
.receive(on: DispatchQueue.main)
|
||||||
on: DispatchQueue.main,
|
|
||||||
// If we haven't done the initial load the trigger it immediately (blocking the main
|
|
||||||
// thread so we remain on the launch screen until it completes to be consistent with
|
|
||||||
// the old behaviour)
|
|
||||||
immediatelyIfMain: !hasLoadedInitialTableData
|
|
||||||
)
|
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { [weak self] result in
|
receiveCompletion: { [weak self] result in
|
||||||
switch result {
|
switch result {
|
||||||
|
@ -334,7 +328,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
.store(in: &disposables)
|
.store(in: &disposables)
|
||||||
|
|
||||||
viewModel.leftNavItems
|
viewModel.leftNavItems
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
|
||||||
.sink { [weak self] maybeItems in
|
.sink { [weak self] maybeItems in
|
||||||
self?.navigationItem.setLeftBarButtonItems(
|
self?.navigationItem.setLeftBarButtonItems(
|
||||||
maybeItems.map { items in
|
maybeItems.map { items in
|
||||||
|
@ -356,7 +349,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
.store(in: &disposables)
|
.store(in: &disposables)
|
||||||
|
|
||||||
viewModel.rightNavItems
|
viewModel.rightNavItems
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
|
||||||
.sink { [weak self] maybeItems in
|
.sink { [weak self] maybeItems in
|
||||||
self?.navigationItem.setRightBarButtonItems(
|
self?.navigationItem.setRightBarButtonItems(
|
||||||
maybeItems.map { items in
|
maybeItems.map { items in
|
||||||
|
@ -378,21 +370,18 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
.store(in: &disposables)
|
.store(in: &disposables)
|
||||||
|
|
||||||
viewModel.emptyStateTextPublisher
|
viewModel.emptyStateTextPublisher
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
|
||||||
.sink { [weak self] text in
|
.sink { [weak self] text in
|
||||||
self?.emptyStateLabel.text = text
|
self?.emptyStateLabel.text = text
|
||||||
}
|
}
|
||||||
.store(in: &disposables)
|
.store(in: &disposables)
|
||||||
|
|
||||||
viewModel.footerView
|
viewModel.footerView
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
|
||||||
.sink { [weak self] footerView in
|
.sink { [weak self] footerView in
|
||||||
self?.tableView.tableFooterView = footerView
|
self?.tableView.tableFooterView = footerView
|
||||||
}
|
}
|
||||||
.store(in: &disposables)
|
.store(in: &disposables)
|
||||||
|
|
||||||
viewModel.footerButtonInfo
|
viewModel.footerButtonInfo
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
|
||||||
.sink { [weak self] buttonInfo in
|
.sink { [weak self] buttonInfo in
|
||||||
if let buttonInfo: SessionButton.Info = buttonInfo {
|
if let buttonInfo: SessionButton.Info = buttonInfo {
|
||||||
self?.footerButton.setTitle(buttonInfo.title, for: .normal)
|
self?.footerButton.setTitle(buttonInfo.title, for: .normal)
|
||||||
|
|
|
@ -55,8 +55,8 @@ public final class BackgroundPoller {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
.subscribe(on: dependencies.subscribeQueue)
|
||||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
.receive(on: dependencies.receiveQueue)
|
||||||
.collect()
|
.collect()
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveCompletion: { result in
|
receiveCompletion: { result in
|
||||||
|
|
|
@ -3,13 +3,11 @@ import GRDB
|
||||||
import SessionSnodeKit
|
import SessionSnodeKit
|
||||||
|
|
||||||
final class IP2Country {
|
final class IP2Country {
|
||||||
var countryNamesCache: Atomic<[String: String]> = Atomic([:])
|
|
||||||
|
|
||||||
|
|
||||||
private static let workQueue = DispatchQueue(label: "IP2Country.workQueue", qos: .utility) // It's important that this is a serial queue
|
|
||||||
static var isInitialized = false
|
static var isInitialized = false
|
||||||
|
|
||||||
// MARK: Tables
|
var countryNamesCache: Atomic<[String: String]> = Atomic([:])
|
||||||
|
|
||||||
|
// MARK: - Tables
|
||||||
/// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains
|
/// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains
|
||||||
/// the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the ID of the country corresponding
|
/// the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the ID of the country corresponding
|
||||||
/// to that range. We look up an IP by finding the first index in the network column where the value is greater than the IP we're looking
|
/// to that range. We look up an IP by finding the first index in the network column where the value is greater than the IP we're looking
|
||||||
|
@ -58,13 +56,12 @@ final class IP2Country {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func populateCacheIfNeededAsync() {
|
@objc func populateCacheIfNeededAsync() {
|
||||||
// This has to be sync since the `countryNamesCache` dict doesn't like async access
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||||
IP2Country.workQueue.sync { [weak self] in
|
self?.populateCacheIfNeeded()
|
||||||
_ = self?.populateCacheIfNeeded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateCacheIfNeeded() -> Bool {
|
@discardableResult func populateCacheIfNeeded() -> Bool {
|
||||||
guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { return false }
|
guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { return false }
|
||||||
|
|
||||||
countryNamesCache.mutate { [weak self] cache in
|
countryNamesCache.mutate { [weak self] cache in
|
||||||
|
|
|
@ -146,19 +146,13 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sendOffer(
|
public func sendOffer(
|
||||||
_ db: Database,
|
to thread: SessionThread,
|
||||||
to sessionId: String,
|
|
||||||
isRestartingICEConnection: Bool = false
|
isRestartingICEConnection: Bool = false
|
||||||
) -> AnyPublisher<Void, Error> {
|
) -> AnyPublisher<Void, Error> {
|
||||||
SNLog("[Calls] Sending offer message.")
|
SNLog("[Calls] Sending offer message.")
|
||||||
let uuid: String = self.uuid
|
let uuid: String = self.uuid
|
||||||
let mediaConstraints: RTCMediaConstraints = mediaConstraints(isRestartingICEConnection)
|
let mediaConstraints: RTCMediaConstraints = mediaConstraints(isRestartingICEConnection)
|
||||||
|
|
||||||
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else {
|
|
||||||
return Fail(error: WebRTCSessionError.noThread)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
return Deferred {
|
return Deferred {
|
||||||
Future<Void, Error> { [weak self] resolver in
|
Future<Void, Error> { [weak self] resolver in
|
||||||
self?.peerConnection?.offer(for: mediaConstraints) { sdp, error in
|
self?.peerConnection?.offer(for: mediaConstraints) { sdp, error in
|
||||||
|
|
|
@ -265,18 +265,6 @@ public extension Profile {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches or creates a Profile for the specified user
|
|
||||||
///
|
|
||||||
/// **Note:** This method intentionally does **not** save the newly created Profile,
|
|
||||||
/// it will need to be explicitly saved after calling
|
|
||||||
static func fetchOrCreate(id: String) -> Profile {
|
|
||||||
let exisingProfile: Profile? = Storage.shared.read { db in
|
|
||||||
try Profile.fetchOne(db, id: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (exisingProfile ?? defaultFor(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches or creates a Profile for the specified user
|
/// Fetches or creates a Profile for the specified user
|
||||||
///
|
///
|
||||||
/// **Note:** This method intentionally does **not** save the newly created Profile,
|
/// **Note:** This method intentionally does **not** save the newly created Profile,
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import GRDB
|
import GRDB
|
||||||
import WebRTC
|
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
/// See https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription for more information.
|
/// See https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription for more information.
|
||||||
|
|
|
@ -7,30 +7,15 @@ import Sodium
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
import SessionSnodeKit
|
import SessionSnodeKit
|
||||||
|
|
||||||
// MARK: - OGMCacheType
|
|
||||||
|
|
||||||
public protocol OGMCacheType {
|
|
||||||
var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>? { get set }
|
|
||||||
var groupImagePublishers: [String: AnyPublisher<Data, Error>] { get set }
|
|
||||||
|
|
||||||
var pollers: [String: OpenGroupAPI.Poller] { get set }
|
|
||||||
var isPolling: Bool { get set }
|
|
||||||
|
|
||||||
var hasPerformedInitialPoll: [String: Bool] { get set }
|
|
||||||
var timeSinceLastPoll: [String: TimeInterval] { get set }
|
|
||||||
|
|
||||||
var pendingChanges: [OpenGroupAPI.PendingChange] { get set }
|
|
||||||
|
|
||||||
func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - OpenGroupManager
|
// MARK: - OpenGroupManager
|
||||||
|
|
||||||
public final class OpenGroupManager {
|
public final class OpenGroupManager {
|
||||||
|
public typealias DefaultRoomInfo = (room: OpenGroupAPI.Room, existingImageData: Data?)
|
||||||
|
|
||||||
// MARK: - Cache
|
// MARK: - Cache
|
||||||
|
|
||||||
public class Cache: OGMCacheType {
|
public class Cache: OGMMutableCacheType {
|
||||||
public var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>?
|
public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>?
|
||||||
public var groupImagePublishers: [String: AnyPublisher<Data, Error>] = [:]
|
public var groupImagePublishers: [String: AnyPublisher<Data, Error>] = [:]
|
||||||
|
|
||||||
public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server
|
public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server
|
||||||
|
@ -60,10 +45,7 @@ public final class OpenGroupManager {
|
||||||
|
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
public static let shared: OpenGroupManager = OpenGroupManager()
|
public static let shared: OpenGroupManager = OpenGroupManager()
|
||||||
|
|
||||||
/// Note: This should not be accessed directly but rather via the 'OGMDependencies' type
|
|
||||||
fileprivate let mutableCache: Atomic<OGMCacheType> = Atomic(Cache())
|
|
||||||
|
|
||||||
// MARK: - Polling
|
// MARK: - Polling
|
||||||
|
|
||||||
|
@ -87,6 +69,7 @@ public final class OpenGroupManager {
|
||||||
}
|
}
|
||||||
.defaulting(to: [])
|
.defaulting(to: [])
|
||||||
|
|
||||||
|
// Update the cache state and re-create all of the pollers
|
||||||
dependencies.mutableCache.mutate { cache in
|
dependencies.mutableCache.mutate { cache in
|
||||||
cache.isPolling = true
|
cache.isPolling = true
|
||||||
cache.pollers = servers
|
cache.pollers = servers
|
||||||
|
@ -94,14 +77,10 @@ public final class OpenGroupManager {
|
||||||
result[server.lowercased()]?.stop() // Should never occur
|
result[server.lowercased()]?.stop() // Should never occur
|
||||||
result[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased())
|
result[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: We loop separately here because when the cache is mocked-out for tests it
|
|
||||||
// doesn't actually store the value (meaning the pollers won't be started), but if
|
|
||||||
// we do it in the 'reduce' function, the 'reduce' result will actually store the
|
|
||||||
// poller value resulting in a bunch of OpenGroup pollers running in a way that can't
|
|
||||||
// be stopped during unit tests
|
|
||||||
cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now that the pollers have been created actually start them
|
||||||
|
dependencies.cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -185,7 +164,7 @@ public final class OpenGroupManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check if there is no poller for the specified server
|
// First check if there is no poller for the specified server
|
||||||
if serverOptions.first(where: { dependencies.cache.pollers[$0] != nil }) == nil {
|
if Set(dependencies.cache.pollers.keys).intersection(serverOptions).isEmpty {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -209,13 +188,11 @@ public final class OpenGroupManager {
|
||||||
publicKey: String,
|
publicKey: String,
|
||||||
calledFromConfigHandling: Bool,
|
calledFromConfigHandling: Bool,
|
||||||
dependencies: OGMDependencies = OGMDependencies()
|
dependencies: OGMDependencies = OGMDependencies()
|
||||||
) -> AnyPublisher<Void, Error> {
|
) -> Bool {
|
||||||
// If we are currently polling for this server and already have a TSGroupThread for this room the do nothing
|
// If we are currently polling for this server and already have a TSGroupThread for this room the do nothing
|
||||||
if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) {
|
if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) {
|
||||||
SNLog("Ignoring join open group attempt (already joined), user initiated: \(!calledFromConfigHandling)")
|
SNLog("Ignoring join open group attempt (already joined), user initiated: \(!calledFromConfigHandling)")
|
||||||
return Just(())
|
return false
|
||||||
.setFailureType(to: Error.self)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the open group information
|
// Store the open group information
|
||||||
|
@ -270,72 +247,86 @@ public final class OpenGroupManager {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// We want to avoid blocking the db write thread so we return a future which resolves once the db transaction completes
|
return true
|
||||||
/// and dispatches the result to another queue, this means that the caller can respond to errors resulting from attepting to
|
}
|
||||||
/// join the community
|
|
||||||
return Future<Void, Error> { resolver in
|
public func performInitialRequestsAfterAdd(
|
||||||
db.afterNextTransactionNested { _ in
|
successfullyAddedGroup: Bool,
|
||||||
OpenGroupAPI.workQueue.async {
|
roomToken: String,
|
||||||
resolver(Result.success(()))
|
server: String,
|
||||||
}
|
publicKey: String,
|
||||||
|
calledFromConfigHandling: Bool,
|
||||||
|
dependencies: OGMDependencies = OGMDependencies()
|
||||||
|
) -> AnyPublisher<Void, Error> {
|
||||||
|
guard successfullyAddedGroup else {
|
||||||
|
return Just(())
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the open group information
|
||||||
|
let targetServer: String = {
|
||||||
|
guard OpenGroupManager.isSessionRunOpenGroup(server: server) else {
|
||||||
|
return server.lowercased()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.flatMap { _ in
|
return OpenGroupAPI.defaultServer
|
||||||
dependencies.storage
|
}()
|
||||||
.readPublisher { db in
|
|
||||||
try OpenGroupAPI
|
return dependencies.storage
|
||||||
.preparedCapabilitiesAndRoom(
|
.readPublisher { db in
|
||||||
db,
|
try OpenGroupAPI
|
||||||
for: roomToken,
|
.preparedCapabilitiesAndRoom(
|
||||||
on: targetServer,
|
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
|
|
||||||
.flatMap { info, response -> Future<Void, Error> in
|
|
||||||
Future<Void, Error> { resolver in
|
|
||||||
dependencies.storage.write { db in
|
|
||||||
// Add the new open group to libSession
|
|
||||||
if !calledFromConfigHandling {
|
|
||||||
try SessionUtil.add(
|
|
||||||
db,
|
|
||||||
server: server,
|
|
||||||
rootToken: roomToken,
|
|
||||||
publicKey: publicKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the capabilities first
|
|
||||||
OpenGroupManager.handleCapabilities(
|
|
||||||
db,
|
db,
|
||||||
capabilities: response.capabilities.data,
|
|
||||||
on: targetServer
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then the room
|
|
||||||
try OpenGroupManager.handlePollInfo(
|
|
||||||
db,
|
|
||||||
pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data),
|
|
||||||
publicKey: publicKey,
|
|
||||||
for: roomToken,
|
for: roomToken,
|
||||||
on: targetServer,
|
on: targetServer,
|
||||||
dependencies: dependencies
|
using: dependencies
|
||||||
) {
|
)
|
||||||
resolver(Result.success(()))
|
}
|
||||||
|
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
|
||||||
|
.flatMap { info, response -> Future<Void, Error> in
|
||||||
|
Future<Void, Error> { resolver in
|
||||||
|
dependencies.storage.write { db in
|
||||||
|
// Add the new open group to libSession
|
||||||
|
if !calledFromConfigHandling {
|
||||||
|
try SessionUtil.add(
|
||||||
|
db,
|
||||||
|
server: server,
|
||||||
|
rootToken: roomToken,
|
||||||
|
publicKey: publicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the capabilities first
|
||||||
|
OpenGroupManager.handleCapabilities(
|
||||||
|
db,
|
||||||
|
capabilities: response.capabilities.data,
|
||||||
|
on: targetServer
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then the room
|
||||||
|
try OpenGroupManager.handlePollInfo(
|
||||||
|
db,
|
||||||
|
pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data),
|
||||||
|
publicKey: publicKey,
|
||||||
|
for: roomToken,
|
||||||
|
on: targetServer,
|
||||||
|
dependencies: dependencies
|
||||||
|
) {
|
||||||
|
resolver(Result.success(()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.handleEvents(
|
||||||
.handleEvents(
|
receiveCompletion: { result in
|
||||||
receiveCompletion: { result in
|
switch result {
|
||||||
switch result {
|
case .finished: break
|
||||||
case .finished: break
|
case .failure: SNLog("Failed to join open group.")
|
||||||
case .failure: SNLog("Failed to join open group.")
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
.eraseToAnyPublisher()
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func delete(
|
public func delete(
|
||||||
|
@ -534,9 +525,11 @@ public final class OpenGroupManager {
|
||||||
// Start the poller if needed
|
// Start the poller if needed
|
||||||
if dependencies.cache.pollers[server.lowercased()] == nil {
|
if dependencies.cache.pollers[server.lowercased()] == nil {
|
||||||
dependencies.mutableCache.mutate {
|
dependencies.mutableCache.mutate {
|
||||||
|
$0.pollers[server.lowercased()]?.stop()
|
||||||
$0.pollers[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased())
|
$0.pollers[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased())
|
||||||
$0.pollers[server.lowercased()]?.startIfNeeded(using: dependencies)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies.cache.pollers[server.lowercased()]?.startIfNeeded(using: dependencies)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start downloading the room image (if we don't have one or it's been updated)
|
/// Start downloading the room image (if we don't have one or it's been updated)
|
||||||
|
@ -549,10 +542,10 @@ public final class OpenGroupManager {
|
||||||
{
|
{
|
||||||
OpenGroupManager
|
OpenGroupManager
|
||||||
.roomImage(
|
.roomImage(
|
||||||
db,
|
|
||||||
fileId: imageId,
|
fileId: imageId,
|
||||||
for: roomToken,
|
for: roomToken,
|
||||||
on: server,
|
on: server,
|
||||||
|
existingData: openGroup.imageData,
|
||||||
using: dependencies
|
using: dependencies
|
||||||
)
|
)
|
||||||
// Note: We need to subscribe and receive on different threads to ensure the
|
// Note: We need to subscribe and receive on different threads to ensure the
|
||||||
|
@ -593,45 +586,26 @@ public final class OpenGroupManager {
|
||||||
on server: String,
|
on server: String,
|
||||||
dependencies: OGMDependencies = OGMDependencies()
|
dependencies: OGMDependencies = OGMDependencies()
|
||||||
) {
|
) {
|
||||||
// Sorting the messages by server ID before importing them fixes an issue where messages
|
|
||||||
// that quote older messages can't find those older messages
|
|
||||||
guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else {
|
guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else {
|
||||||
SNLog("Couldn't handle open group messages.")
|
SNLog("Couldn't handle open group messages.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sorting the messages by server ID before importing them fixes an issue where messages
|
||||||
|
// that quote older messages can't find those older messages
|
||||||
let sortedMessages: [OpenGroupAPI.Message] = messages
|
let sortedMessages: [OpenGroupAPI.Message] = messages
|
||||||
.filter { $0.deleted != true }
|
.filter { $0.deleted != true }
|
||||||
.sorted { lhs, rhs in lhs.id < rhs.id }
|
.sorted { lhs, rhs in lhs.id < rhs.id }
|
||||||
var messageServerInfoToRemove: [(id: Int64, seqNo: Int64)] = messages
|
var messageServerInfoToRemove: [(id: Int64, seqNo: Int64)] = messages
|
||||||
.filter { $0.deleted == true }
|
.filter { $0.deleted == true }
|
||||||
.map { ($0.id, $0.seqNo) }
|
.map { ($0.id, $0.seqNo) }
|
||||||
let updateSeqNo: (Database, String, inout Int64, Int64, OGMDependencies) -> () = { db, openGroupId, lastValidSeqNo, seqNo, dependencies in
|
var largestValidSeqNo: Int64 = openGroup.sequenceNumber
|
||||||
// Only update the data if the 'seqNo' is larger than the lastValidSeqNo (only want it to increase)
|
|
||||||
guard seqNo > lastValidSeqNo else { return }
|
|
||||||
|
|
||||||
// Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId')
|
|
||||||
_ = try? OpenGroup
|
|
||||||
.filter(id: openGroupId)
|
|
||||||
.updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: seqNo))
|
|
||||||
|
|
||||||
// Update pendingChange cache
|
|
||||||
dependencies.mutableCache.mutate {
|
|
||||||
$0.pendingChanges = $0.pendingChanges
|
|
||||||
.filter { $0.seqNo == nil || $0.seqNo! > seqNo }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the inout value
|
|
||||||
lastValidSeqNo = seqNo
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the messages
|
// Process the messages
|
||||||
var lastValidSeqNo: Int64 = -1
|
|
||||||
sortedMessages.forEach { message in
|
sortedMessages.forEach { message in
|
||||||
if message.base64EncodedData == nil && message.reactions == nil {
|
if message.base64EncodedData == nil && message.reactions == nil {
|
||||||
messageServerInfoToRemove.append((message.id, message.seqNo))
|
messageServerInfoToRemove.append((message.id, message.seqNo))
|
||||||
|
return
|
||||||
return updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle messages
|
// Handle messages
|
||||||
|
@ -658,7 +632,7 @@ public final class OpenGroupManager {
|
||||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||||
dependencies: dependencies
|
dependencies: dependencies
|
||||||
)
|
)
|
||||||
updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies)
|
largestValidSeqNo = max(largestValidSeqNo, message.seqNo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
@ -703,7 +677,7 @@ public final class OpenGroupManager {
|
||||||
openGroupMessageServerId: message.id,
|
openGroupMessageServerId: message.id,
|
||||||
openGroupReactions: reactions
|
openGroupReactions: reactions
|
||||||
)
|
)
|
||||||
updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies)
|
largestValidSeqNo = max(largestValidSeqNo, message.seqNo)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
SNLog("Couldn't handle open group reactions due to error: \(error).")
|
SNLog("Couldn't handle open group reactions due to error: \(error).")
|
||||||
|
@ -712,17 +686,27 @@ public final class OpenGroupManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle any deletions that are needed
|
// Handle any deletions that are needed
|
||||||
guard !messageServerInfoToRemove.isEmpty else { return }
|
if !messageServerInfoToRemove.isEmpty {
|
||||||
|
let messageServerIdsToRemove: [Int64] = messageServerInfoToRemove.map { $0.id }
|
||||||
|
_ = try? Interaction
|
||||||
|
.filter(Interaction.Columns.threadId == openGroup.threadId)
|
||||||
|
.filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId))
|
||||||
|
.deleteAll(db)
|
||||||
|
|
||||||
|
// Update the seqNo for deletions
|
||||||
|
largestValidSeqNo = max(largestValidSeqNo, (messageServerInfoToRemove.map({ $0.seqNo }).max() ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
let messageServerIdsToRemove: [Int64] = messageServerInfoToRemove.map { $0.id }
|
// Now that we've finished processing all valid message changes we can update the `sequenceNumber` to
|
||||||
_ = try? Interaction
|
// the `largestValidSeqNo` value
|
||||||
.filter(Interaction.Columns.threadId == openGroup.threadId)
|
_ = try? OpenGroup
|
||||||
.filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId))
|
.filter(id: openGroup.id)
|
||||||
.deleteAll(db)
|
.updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo))
|
||||||
|
|
||||||
// Update the seqNo for deletions
|
// Update pendingChange cache based on the `largestValidSeqNo` value
|
||||||
if let lastDeletionSeqNo: Int64 = messageServerInfoToRemove.map({ $0.seqNo }).max() {
|
dependencies.mutableCache.mutate {
|
||||||
updateSeqNo(db, openGroup.id, &lastValidSeqNo, lastDeletionSeqNo, dependencies)
|
$0.pendingChanges = $0.pendingChanges
|
||||||
|
.filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1013,14 +997,14 @@ public final class OpenGroupManager {
|
||||||
subscribeQueue: OpenGroupAPI.workQueue,
|
subscribeQueue: OpenGroupAPI.workQueue,
|
||||||
receiveQueue: OpenGroupAPI.workQueue
|
receiveQueue: OpenGroupAPI.workQueue
|
||||||
)
|
)
|
||||||
) -> AnyPublisher<[OpenGroupAPI.Room], Error> {
|
) -> AnyPublisher<[DefaultRoomInfo], Error> {
|
||||||
// Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again
|
// Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again
|
||||||
if let existingPublisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.cache.defaultRoomsPublisher {
|
if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.cache.defaultRoomsPublisher {
|
||||||
return existingPublisher
|
return existingPublisher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to retrieve the default rooms 8 times
|
// Try to retrieve the default rooms 8 times
|
||||||
let publisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.storage
|
let publisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.storage
|
||||||
.readPublisher { db in
|
.readPublisher { db in
|
||||||
try OpenGroupAPI.preparedCapabilitiesAndRooms(
|
try OpenGroupAPI.preparedCapabilitiesAndRooms(
|
||||||
db,
|
db,
|
||||||
|
@ -1029,11 +1013,11 @@ public final class OpenGroupManager {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
|
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
|
||||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
.subscribe(on: dependencies.subscribeQueue)
|
||||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
.receive(on: dependencies.receiveQueue)
|
||||||
.retry(8)
|
.retry(8)
|
||||||
.map { info, response in
|
.map { info, response -> [DefaultRoomInfo]? in
|
||||||
dependencies.storage.writeAsync { db in
|
dependencies.storage.write { db -> [DefaultRoomInfo] in
|
||||||
// Store the capabilities first
|
// Store the capabilities first
|
||||||
OpenGroupManager.handleCapabilities(
|
OpenGroupManager.handleCapabilities(
|
||||||
db,
|
db,
|
||||||
|
@ -1042,8 +1026,8 @@ public final class OpenGroupManager {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Then the rooms
|
// Then the rooms
|
||||||
response.rooms.data
|
return response.rooms.data
|
||||||
.compactMap { room -> (String, String)? in
|
.map { room -> DefaultRoomInfo in
|
||||||
// Try to insert an inactive version of the OpenGroup (use 'insert'
|
// Try to insert an inactive version of the OpenGroup (use 'insert'
|
||||||
// rather than 'save' as we want it to fail if the room already exists)
|
// rather than 'save' as we want it to fail if the room already exists)
|
||||||
do {
|
do {
|
||||||
|
@ -1066,24 +1050,32 @@ public final class OpenGroupManager {
|
||||||
}
|
}
|
||||||
catch {}
|
catch {}
|
||||||
|
|
||||||
guard let imageId: String = room.imageId else { return nil }
|
// Retrieve existing image data if we have it
|
||||||
|
let existingImageData: Data? = try? OpenGroup
|
||||||
|
.select(.imageData)
|
||||||
|
.filter(id: OpenGroup.idFor(roomToken: room.token, server: OpenGroupAPI.defaultServer))
|
||||||
|
.asRequest(of: Data.self)
|
||||||
|
.fetchOne(db)
|
||||||
|
|
||||||
return (imageId, room.token)
|
return (room, existingImageData)
|
||||||
}
|
|
||||||
.forEach { imageId, roomToken in
|
|
||||||
roomImage(
|
|
||||||
db,
|
|
||||||
fileId: imageId,
|
|
||||||
for: roomToken,
|
|
||||||
on: OpenGroupAPI.defaultServer,
|
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.rooms.data
|
|
||||||
}
|
}
|
||||||
|
.map { ($0 ?? []) }
|
||||||
.handleEvents(
|
.handleEvents(
|
||||||
|
receiveOutput: { roomInfo in
|
||||||
|
roomInfo.forEach { room, existingImageData in
|
||||||
|
guard let imageId: String = room.imageId else { return }
|
||||||
|
|
||||||
|
roomImage(
|
||||||
|
fileId: imageId,
|
||||||
|
for: room.token,
|
||||||
|
on: OpenGroupAPI.defaultServer,
|
||||||
|
existingData: existingImageData,
|
||||||
|
using: dependencies
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
receiveCompletion: { result in
|
receiveCompletion: { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .finished: break
|
case .finished: break
|
||||||
|
@ -1108,10 +1100,10 @@ public final class OpenGroupManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult public static func roomImage(
|
@discardableResult public static func roomImage(
|
||||||
_ db: Database,
|
|
||||||
fileId: String,
|
fileId: String,
|
||||||
for roomToken: String,
|
for roomToken: String,
|
||||||
on server: String,
|
on server: String,
|
||||||
|
existingData: Data?,
|
||||||
using dependencies: OGMDependencies = OGMDependencies(
|
using dependencies: OGMDependencies = OGMDependencies(
|
||||||
subscribeQueue: .global(qos: .background)
|
subscribeQueue: .global(qos: .background)
|
||||||
)
|
)
|
||||||
|
@ -1130,16 +1122,12 @@ public final class OpenGroupManager {
|
||||||
let now: Date = dependencies.date
|
let now: Date = dependencies.date
|
||||||
let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude)
|
let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude)
|
||||||
let updateInterval: TimeInterval = (7 * 24 * 60 * 60)
|
let updateInterval: TimeInterval = (7 * 24 * 60 * 60)
|
||||||
|
let canUseExistingImage: Bool = (
|
||||||
|
server.lowercased() == OpenGroupAPI.defaultServer &&
|
||||||
|
timeSinceLastUpdate < updateInterval
|
||||||
|
)
|
||||||
|
|
||||||
if
|
if canUseExistingImage, let data: Data = existingData {
|
||||||
server.lowercased() == OpenGroupAPI.defaultServer,
|
|
||||||
timeSinceLastUpdate < updateInterval,
|
|
||||||
let data = try? OpenGroup
|
|
||||||
.select(.imageData)
|
|
||||||
.filter(id: threadId)
|
|
||||||
.asRequest(of: Data.self)
|
|
||||||
.fetchOne(db)
|
|
||||||
{
|
|
||||||
return Just(data)
|
return Just(data)
|
||||||
.setFailureType(to: Error.self)
|
.setFailureType(to: Error.self)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -1149,33 +1137,54 @@ public final class OpenGroupManager {
|
||||||
return publisher
|
return publisher
|
||||||
}
|
}
|
||||||
|
|
||||||
let sendData: OpenGroupAPI.PreparedSendData<Data>
|
|
||||||
|
|
||||||
do {
|
|
||||||
sendData = try OpenGroupAPI
|
|
||||||
.preparedDownloadFile(
|
|
||||||
db,
|
|
||||||
fileId: fileId,
|
|
||||||
from: roomToken,
|
|
||||||
on: server,
|
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return Fail(error: error)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defer the actual download and run it on a separate thread to avoid blocking the calling thread
|
// Defer the actual download and run it on a separate thread to avoid blocking the calling thread
|
||||||
let publisher: AnyPublisher<Data, Error> = Deferred {
|
let publisher: AnyPublisher<Data, Error> = Deferred {
|
||||||
Future { resolver in
|
Future { resolver in
|
||||||
dependencies.subscribeQueue.async {
|
dependencies.subscribeQueue.async {
|
||||||
// Hold on to the publisher until it has completed at least once
|
// Hold on to the publisher until it has completed at least once
|
||||||
OpenGroupAPI
|
dependencies.storage
|
||||||
.send(
|
.readPublisher { db -> (Data?, OpenGroupAPI.PreparedSendData<Data>?) in
|
||||||
data: sendData,
|
if canUseExistingImage {
|
||||||
using: dependencies
|
let maybeExistingData: Data? = try? OpenGroup
|
||||||
)
|
.select(.imageData)
|
||||||
|
.filter(id: threadId)
|
||||||
|
.asRequest(of: Data.self)
|
||||||
|
.fetchOne(db)
|
||||||
|
|
||||||
|
if let existingData: Data = maybeExistingData {
|
||||||
|
return (existingData, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
nil,
|
||||||
|
try OpenGroupAPI
|
||||||
|
.preparedDownloadFile(
|
||||||
|
db,
|
||||||
|
fileId: fileId,
|
||||||
|
from: roomToken,
|
||||||
|
on: server,
|
||||||
|
using: dependencies
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.flatMap { info in
|
||||||
|
switch info {
|
||||||
|
case (.some(let existingData), _):
|
||||||
|
return Just(existingData)
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
|
case (_, .some(let sendData)):
|
||||||
|
return OpenGroupAPI.send(data: sendData, using: dependencies)
|
||||||
|
.map { _, imageData in imageData }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Fail(error: HTTPError.generic)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveCompletion: { result in
|
receiveCompletion: { result in
|
||||||
switch result {
|
switch result {
|
||||||
|
@ -1183,7 +1192,7 @@ public final class OpenGroupManager {
|
||||||
case .failure(let error): resolver(Result.failure(error))
|
case .failure(let error): resolver(Result.failure(error))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
receiveValue: { _, imageData in
|
receiveValue: { imageData in
|
||||||
if server.lowercased() == OpenGroupAPI.defaultServer {
|
if server.lowercased() == OpenGroupAPI.defaultServer {
|
||||||
dependencies.storage.write { db in
|
dependencies.storage.write { db in
|
||||||
_ = try OpenGroup
|
_ = try OpenGroup
|
||||||
|
@ -1216,25 +1225,73 @@ public final class OpenGroupManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - OGMCacheType
|
||||||
|
|
||||||
|
public protocol OGMMutableCacheType: OGMCacheType {
|
||||||
|
var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get set }
|
||||||
|
var groupImagePublishers: [String: AnyPublisher<Data, Error>] { get set }
|
||||||
|
|
||||||
|
var pollers: [String: OpenGroupAPI.Poller] { get set }
|
||||||
|
var isPolling: Bool { get set }
|
||||||
|
|
||||||
|
var hasPerformedInitialPoll: [String: Bool] { get set }
|
||||||
|
var timeSinceLastPoll: [String: TimeInterval] { get set }
|
||||||
|
|
||||||
|
var pendingChanges: [OpenGroupAPI.PendingChange] { get set }
|
||||||
|
|
||||||
|
func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a
|
||||||
|
/// non-thread-safe way
|
||||||
|
public protocol OGMCacheType {
|
||||||
|
var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get }
|
||||||
|
var groupImagePublishers: [String: AnyPublisher<Data, Error>] { get }
|
||||||
|
|
||||||
|
var pollers: [String: OpenGroupAPI.Poller] { get }
|
||||||
|
var isPolling: Bool { get }
|
||||||
|
|
||||||
|
var hasPerformedInitialPoll: [String: Bool] { get }
|
||||||
|
var timeSinceLastPoll: [String: TimeInterval] { get }
|
||||||
|
|
||||||
|
var pendingChanges: [OpenGroupAPI.PendingChange] { get }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - OGMDependencies
|
// MARK: - OGMDependencies
|
||||||
|
|
||||||
extension OpenGroupManager {
|
extension OpenGroupManager {
|
||||||
public class OGMDependencies: SMKDependencies {
|
public class OGMDependencies: SMKDependencies {
|
||||||
internal var _mutableCache: Atomic<Atomic<OGMCacheType>?>
|
/// These should not be accessed directly but rather via an instance of this type
|
||||||
public var mutableCache: Atomic<OGMCacheType> {
|
private static let _cacheInstance: OGMMutableCacheType = OpenGroupManager.Cache()
|
||||||
get { Dependencies.getValueSettingIfNull(&_mutableCache) { OpenGroupManager.shared.mutableCache } }
|
private static let _cacheInstanceAccessQueue = DispatchQueue(label: "OGMCacheInstanceAccess")
|
||||||
set { _mutableCache.mutate { $0 = newValue } }
|
|
||||||
}
|
|
||||||
|
|
||||||
public var cache: OGMCacheType { return mutableCache.wrappedValue }
|
internal var _mutableCache: Atomic<OGMMutableCacheType?>
|
||||||
|
public var mutableCache: Atomic<OGMMutableCacheType> {
|
||||||
|
get {
|
||||||
|
Dependencies.getMutableValueSettingIfNull(&_mutableCache) {
|
||||||
|
OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public var cache: OGMCacheType {
|
||||||
|
get {
|
||||||
|
Dependencies.getValueSettingIfNull(&_mutableCache) {
|
||||||
|
OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let mutableValue: OGMMutableCacheType = newValue as? OGMMutableCacheType else { return }
|
||||||
|
|
||||||
|
_mutableCache.mutate { $0 = mutableValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
subscribeQueue: DispatchQueue? = nil,
|
subscribeQueue: DispatchQueue? = nil,
|
||||||
receiveQueue: DispatchQueue? = nil,
|
receiveQueue: DispatchQueue? = nil,
|
||||||
cache: Atomic<OGMCacheType>? = nil,
|
cache: OGMMutableCacheType? = nil,
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: MutableGeneralCacheType? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
scheduler: ValueObservationScheduler? = nil,
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
sodium: SodiumType? = nil,
|
sodium: SodiumType? = nil,
|
||||||
|
|
|
@ -8,6 +8,7 @@ public enum OpenGroupAPIError: LocalizedError {
|
||||||
case noPublicKey
|
case noPublicKey
|
||||||
case invalidEmoji
|
case invalidEmoji
|
||||||
case invalidPreparedData
|
case invalidPreparedData
|
||||||
|
case invalidPoll
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -16,6 +17,7 @@ public enum OpenGroupAPIError: LocalizedError {
|
||||||
case .noPublicKey: return "Couldn't find server public key."
|
case .noPublicKey: return "Couldn't find server public key."
|
||||||
case .invalidEmoji: return "The emoji is invalid."
|
case .invalidEmoji: return "The emoji is invalid."
|
||||||
case .invalidPreparedData: return "Invalid PreparedSendData provided."
|
case .invalidPreparedData: return "Invalid PreparedSendData provided."
|
||||||
|
case .invalidPoll: return "Poller in invalid state."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,7 +61,7 @@ public class SMKDependencies: SSKDependencies {
|
||||||
subscribeQueue: DispatchQueue? = nil,
|
subscribeQueue: DispatchQueue? = nil,
|
||||||
receiveQueue: DispatchQueue? = nil,
|
receiveQueue: DispatchQueue? = nil,
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: MutableGeneralCacheType? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
scheduler: ValueObservationScheduler? = nil,
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
sodium: SodiumType? = nil,
|
sodium: SodiumType? = nil,
|
||||||
|
|
|
@ -46,6 +46,10 @@ extension MessageReceiver {
|
||||||
private static func handleNewCallMessage(_ db: Database, message: CallMessage) throws {
|
private static func handleNewCallMessage(_ db: Database, message: CallMessage) throws {
|
||||||
SNLog("[Calls] Received pre-offer message.")
|
SNLog("[Calls] Received pre-offer message.")
|
||||||
|
|
||||||
|
// Determine whether the app is active based on the prefs rather than the UIApplication state to avoid
|
||||||
|
// requiring main-thread execution
|
||||||
|
let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false)
|
||||||
|
|
||||||
// It is enough just ignoring the pre offers, other call messages
|
// It is enough just ignoring the pre offers, other call messages
|
||||||
// for this call would be dropped because of no Session call instance
|
// for this call would be dropped because of no Session call instance
|
||||||
guard
|
guard
|
||||||
|
@ -69,7 +73,8 @@ extension MessageReceiver {
|
||||||
.notifyUser(
|
.notifyUser(
|
||||||
db,
|
db,
|
||||||
forIncomingCall: interaction,
|
forIncomingCall: interaction,
|
||||||
in: thread
|
in: thread,
|
||||||
|
applicationState: (isMainAppActive ? .active : .background)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -86,7 +91,8 @@ extension MessageReceiver {
|
||||||
.notifyUser(
|
.notifyUser(
|
||||||
db,
|
db,
|
||||||
forIncomingCall: interaction,
|
forIncomingCall: interaction,
|
||||||
in: thread
|
in: thread,
|
||||||
|
applicationState: (isMainAppActive ? .active : .background)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -200,7 +200,7 @@ extension MessageReceiver {
|
||||||
// Open groups
|
// Open groups
|
||||||
for openGroupURL in message.openGroups {
|
for openGroupURL in message.openGroups {
|
||||||
if let (room, server, publicKey) = SessionUtil.parseCommunity(url: openGroupURL) {
|
if let (room, server, publicKey) = SessionUtil.parseCommunity(url: openGroupURL) {
|
||||||
OpenGroupManager.shared
|
let successfullyAddedGroup: Bool = OpenGroupManager.shared
|
||||||
.add(
|
.add(
|
||||||
db,
|
db,
|
||||||
roomToken: room,
|
roomToken: room,
|
||||||
|
@ -208,7 +208,20 @@ extension MessageReceiver {
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
calledFromConfigHandling: true
|
calledFromConfigHandling: true
|
||||||
)
|
)
|
||||||
.sinkUntilComplete()
|
|
||||||
|
if successfullyAddedGroup {
|
||||||
|
db.afterNextTransactionNested { _ in
|
||||||
|
OpenGroupManager.shared.performInitialRequestsAfterAdd(
|
||||||
|
successfullyAddedGroup: successfullyAddedGroup,
|
||||||
|
roomToken: room,
|
||||||
|
server: server,
|
||||||
|
publicKey: publicKey,
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
|
.subscribe(on: OpenGroupAPI.workQueue)
|
||||||
|
.sinkUntilComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -321,7 +321,8 @@ extension MessageReceiver {
|
||||||
.notifyUser(
|
.notifyUser(
|
||||||
db,
|
db,
|
||||||
for: interaction,
|
for: interaction,
|
||||||
in: thread
|
in: thread,
|
||||||
|
applicationState: (isMainAppActive ? .active : .background)
|
||||||
)
|
)
|
||||||
|
|
||||||
return interactionId
|
return interactionId
|
||||||
|
@ -362,6 +363,9 @@ extension MessageReceiver {
|
||||||
|
|
||||||
switch reaction.kind {
|
switch reaction.kind {
|
||||||
case .react:
|
case .react:
|
||||||
|
// Determine whether the app is active based on the prefs rather than the UIApplication state to avoid
|
||||||
|
// requiring main-thread execution
|
||||||
|
let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false)
|
||||||
let timestampMs: Int64 = Int64(messageSentTimestamp * 1000)
|
let timestampMs: Int64 = Int64(messageSentTimestamp * 1000)
|
||||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
let reaction: Reaction = try Reaction(
|
let reaction: Reaction = try Reaction(
|
||||||
|
@ -388,7 +392,8 @@ extension MessageReceiver {
|
||||||
.notifyUser(
|
.notifyUser(
|
||||||
db,
|
db,
|
||||||
forReaction: reaction,
|
forReaction: reaction,
|
||||||
in: thread
|
in: thread,
|
||||||
|
applicationState: (isMainAppActive ? .active : .background)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .remove:
|
case .remove:
|
||||||
|
|
|
@ -12,130 +12,130 @@ extension MessageSender {
|
||||||
public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:])
|
public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:])
|
||||||
|
|
||||||
public static func createClosedGroup(
|
public static func createClosedGroup(
|
||||||
_ db: Database,
|
|
||||||
name: String,
|
name: String,
|
||||||
members: Set<String>
|
members: Set<String>
|
||||||
) throws -> AnyPublisher<SessionThread, Error> {
|
) -> AnyPublisher<SessionThread, Error> {
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
Storage.shared
|
||||||
var members: Set<String> = members
|
.writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData]) in
|
||||||
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
// Generate the group's public key
|
var members: Set<String> = members
|
||||||
let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair()
|
|
||||||
let groupPublicKey: String = KeyPair(
|
// Generate the group's public key
|
||||||
publicKey: groupKeyPair.publicKey.bytes,
|
let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair()
|
||||||
secretKey: groupKeyPair.privateKey.bytes
|
let groupPublicKey: String = KeyPair(
|
||||||
).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix
|
publicKey: groupKeyPair.publicKey.bytes,
|
||||||
// Generate the key pair that'll be used for encryption and decryption
|
secretKey: groupKeyPair.privateKey.bytes
|
||||||
let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair()
|
).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix
|
||||||
|
// Generate the key pair that'll be used for encryption and decryption
|
||||||
// Create the group
|
let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair()
|
||||||
members.insert(userPublicKey) // Ensure the current user is included in the member list
|
|
||||||
let membersAsData: [Data] = members.map { Data(hex: $0) }
|
// Create the group
|
||||||
let admins: Set<String> = [ userPublicKey ]
|
members.insert(userPublicKey) // Ensure the current user is included in the member list
|
||||||
let adminsAsData: [Data] = admins.map { Data(hex: $0) }
|
let membersAsData: [Data] = members.map { Data(hex: $0) }
|
||||||
let formationTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
|
let admins: Set<String> = [ userPublicKey ]
|
||||||
|
let adminsAsData: [Data] = admins.map { Data(hex: $0) }
|
||||||
// Create the relevant objects in the database
|
let formationTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
|
||||||
let thread: SessionThread = try SessionThread
|
|
||||||
.fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true)
|
// Create the relevant objects in the database
|
||||||
try ClosedGroup(
|
let thread: SessionThread = try SessionThread
|
||||||
threadId: groupPublicKey,
|
.fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true)
|
||||||
name: name,
|
try ClosedGroup(
|
||||||
formationTimestamp: formationTimestamp
|
threadId: groupPublicKey,
|
||||||
).insert(db)
|
name: name,
|
||||||
|
formationTimestamp: formationTimestamp
|
||||||
// Store the key pair
|
).insert(db)
|
||||||
let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
|
|
||||||
try ClosedGroupKeyPair(
|
// Store the key pair
|
||||||
threadId: groupPublicKey,
|
let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
|
||||||
publicKey: encryptionKeyPair.publicKey,
|
try ClosedGroupKeyPair(
|
||||||
secretKey: encryptionKeyPair.privateKey,
|
threadId: groupPublicKey,
|
||||||
receivedTimestamp: latestKeyPairReceivedTimestamp
|
publicKey: encryptionKeyPair.publicKey,
|
||||||
).insert(db)
|
secretKey: encryptionKeyPair.privateKey,
|
||||||
|
receivedTimestamp: latestKeyPairReceivedTimestamp
|
||||||
// Create the member objects
|
).insert(db)
|
||||||
try admins.forEach { adminId in
|
|
||||||
try GroupMember(
|
// Create the member objects
|
||||||
groupId: groupPublicKey,
|
try admins.forEach { adminId in
|
||||||
profileId: adminId,
|
try GroupMember(
|
||||||
role: .admin,
|
groupId: groupPublicKey,
|
||||||
isHidden: false
|
profileId: adminId,
|
||||||
).save(db)
|
role: .admin,
|
||||||
}
|
isHidden: false
|
||||||
|
).save(db)
|
||||||
try members.forEach { memberId in
|
}
|
||||||
try GroupMember(
|
|
||||||
groupId: groupPublicKey,
|
try members.forEach { memberId in
|
||||||
profileId: memberId,
|
try GroupMember(
|
||||||
role: .standard,
|
groupId: groupPublicKey,
|
||||||
isHidden: false
|
profileId: memberId,
|
||||||
).save(db)
|
role: .standard,
|
||||||
}
|
isHidden: false
|
||||||
|
).save(db)
|
||||||
// Update libSession
|
}
|
||||||
try SessionUtil.add(
|
|
||||||
db,
|
// Update libSession
|
||||||
groupPublicKey: groupPublicKey,
|
try SessionUtil.add(
|
||||||
name: name,
|
|
||||||
latestKeyPairPublicKey: encryptionKeyPair.publicKey,
|
|
||||||
latestKeyPairSecretKey: encryptionKeyPair.privateKey,
|
|
||||||
latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp,
|
|
||||||
disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey),
|
|
||||||
members: members,
|
|
||||||
admins: admins
|
|
||||||
)
|
|
||||||
|
|
||||||
let memberSendData: [MessageSender.PreparedSendData] = try members
|
|
||||||
.map { memberId -> MessageSender.PreparedSendData in
|
|
||||||
try MessageSender.preparedSendData(
|
|
||||||
db,
|
db,
|
||||||
message: ClosedGroupControlMessage(
|
groupPublicKey: groupPublicKey,
|
||||||
kind: .new(
|
name: name,
|
||||||
publicKey: Data(hex: groupPublicKey),
|
latestKeyPairPublicKey: encryptionKeyPair.publicKey,
|
||||||
name: name,
|
latestKeyPairSecretKey: encryptionKeyPair.privateKey,
|
||||||
encryptionKeyPair: KeyPair(
|
latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp,
|
||||||
publicKey: encryptionKeyPair.publicKey.bytes,
|
disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey),
|
||||||
secretKey: encryptionKeyPair.privateKey.bytes
|
members: members,
|
||||||
),
|
admins: admins
|
||||||
members: membersAsData,
|
|
||||||
admins: adminsAsData,
|
|
||||||
expirationTimer: 0
|
|
||||||
),
|
|
||||||
// Note: We set this here to ensure the value matches
|
|
||||||
// the 'ClosedGroup' object we created
|
|
||||||
sentTimestampMs: UInt64(floor(formationTimestamp * 1000))
|
|
||||||
),
|
|
||||||
to: .contact(publicKey: memberId),
|
|
||||||
namespace: Message.Destination.contact(publicKey: memberId).defaultNamespace,
|
|
||||||
interactionId: nil
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
let memberSendData: [MessageSender.PreparedSendData] = try members
|
||||||
return Publishers
|
.map { memberId -> MessageSender.PreparedSendData in
|
||||||
.MergeMany(
|
try MessageSender.preparedSendData(
|
||||||
// Send a closed group update message to all members individually
|
db,
|
||||||
memberSendData
|
message: ClosedGroupControlMessage(
|
||||||
.map { MessageSender.sendImmediate(preparedSendData: $0) }
|
kind: .new(
|
||||||
.appending(
|
publicKey: Data(hex: groupPublicKey),
|
||||||
// Notify the PN server
|
name: name,
|
||||||
PushNotificationAPI.performOperation(
|
encryptionKeyPair: KeyPair(
|
||||||
.subscribe,
|
publicKey: encryptionKeyPair.publicKey.bytes,
|
||||||
for: groupPublicKey,
|
secretKey: encryptionKeyPair.privateKey.bytes
|
||||||
publicKey: userPublicKey
|
),
|
||||||
|
members: membersAsData,
|
||||||
|
admins: adminsAsData,
|
||||||
|
expirationTimer: 0
|
||||||
|
),
|
||||||
|
// Note: We set this here to ensure the value matches
|
||||||
|
// the 'ClosedGroup' object we created
|
||||||
|
sentTimestampMs: UInt64(floor(formationTimestamp * 1000))
|
||||||
|
),
|
||||||
|
to: .contact(publicKey: memberId),
|
||||||
|
namespace: Message.Destination.contact(publicKey: memberId).defaultNamespace,
|
||||||
|
interactionId: nil
|
||||||
)
|
)
|
||||||
)
|
|
||||||
)
|
|
||||||
.collect()
|
|
||||||
.map { _ in thread }
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
.handleEvents(
|
|
||||||
receiveCompletion: { result in
|
|
||||||
switch result {
|
|
||||||
case .failure: break
|
|
||||||
case .finished:
|
|
||||||
// Start polling
|
|
||||||
ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (userPublicKey, thread, memberSendData)
|
||||||
|
}
|
||||||
|
.flatMap { userPublicKey, thread, memberSendData in
|
||||||
|
Publishers
|
||||||
|
.MergeMany(
|
||||||
|
// Send a closed group update message to all members individually
|
||||||
|
memberSendData
|
||||||
|
.map { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||||
|
.appending(
|
||||||
|
// Notify the PN server
|
||||||
|
PushNotificationAPI.performOperation(
|
||||||
|
.subscribe,
|
||||||
|
for: thread.id,
|
||||||
|
publicKey: userPublicKey
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
.map { _ in thread }
|
||||||
|
}
|
||||||
|
.handleEvents(
|
||||||
|
receiveOutput: { thread in
|
||||||
|
// Start polling
|
||||||
|
ClosedGroupPoller.shared.startIfNeeded(for: thread.id)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -148,7 +148,6 @@ extension MessageSender {
|
||||||
///
|
///
|
||||||
/// The returned promise is fulfilled when the message has been sent to the group.
|
/// The returned promise is fulfilled when the message has been sent to the group.
|
||||||
private static func generateAndSendNewEncryptionKeyPair(
|
private static func generateAndSendNewEncryptionKeyPair(
|
||||||
_ db: Database,
|
|
||||||
targetMembers: Set<String>,
|
targetMembers: Set<String>,
|
||||||
userPublicKey: String,
|
userPublicKey: String,
|
||||||
allGroupMembers: [GroupMember],
|
allGroupMembers: [GroupMember],
|
||||||
|
@ -159,65 +158,62 @@ extension MessageSender {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
let newKeyPair: ClosedGroupKeyPair
|
return Storage.shared
|
||||||
let sendData: MessageSender.PreparedSendData
|
.readPublisher { db -> (ClosedGroupKeyPair, MessageSender.PreparedSendData) in
|
||||||
|
// Generate the new encryption key pair
|
||||||
do {
|
let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair()
|
||||||
// Generate the new encryption key pair
|
let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair(
|
||||||
let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair()
|
threadId: closedGroup.threadId,
|
||||||
newKeyPair = ClosedGroupKeyPair(
|
publicKey: legacyNewKeyPair.publicKey,
|
||||||
threadId: closedGroup.threadId,
|
secretKey: legacyNewKeyPair.privateKey,
|
||||||
publicKey: legacyNewKeyPair.publicKey,
|
receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
|
||||||
secretKey: legacyNewKeyPair.privateKey,
|
|
||||||
receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Distribute it
|
|
||||||
let proto = try SNProtoKeyPair.builder(
|
|
||||||
publicKey: newKeyPair.publicKey,
|
|
||||||
privateKey: newKeyPair.secretKey
|
|
||||||
).build()
|
|
||||||
let plaintext = try proto.serializedData()
|
|
||||||
|
|
||||||
distributingKeyPairs.mutate {
|
|
||||||
$0[closedGroup.id] = ($0[closedGroup.id] ?? [])
|
|
||||||
.appending(newKeyPair)
|
|
||||||
}
|
|
||||||
|
|
||||||
sendData = try MessageSender
|
|
||||||
.preparedSendData(
|
|
||||||
db,
|
|
||||||
message: ClosedGroupControlMessage(
|
|
||||||
kind: .encryptionKeyPair(
|
|
||||||
publicKey: nil,
|
|
||||||
wrappers: targetMembers.map { memberPublicKey in
|
|
||||||
ClosedGroupControlMessage.KeyPairWrapper(
|
|
||||||
publicKey: memberPublicKey,
|
|
||||||
encryptedKeyPair: try MessageSender.encryptWithSessionProtocol(
|
|
||||||
db,
|
|
||||||
plaintext: plaintext,
|
|
||||||
for: memberPublicKey
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
),
|
|
||||||
to: try Message.Destination
|
|
||||||
.from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup),
|
|
||||||
namespace: try Message.Destination
|
|
||||||
.from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup)
|
|
||||||
.defaultNamespace,
|
|
||||||
interactionId: nil
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
catch {
|
// Distribute it
|
||||||
return Fail(error: error)
|
let proto = try SNProtoKeyPair.builder(
|
||||||
.eraseToAnyPublisher()
|
publicKey: newKeyPair.publicKey,
|
||||||
}
|
privateKey: newKeyPair.secretKey
|
||||||
|
).build()
|
||||||
return MessageSender.sendImmediate(preparedSendData: sendData)
|
let plaintext = try proto.serializedData()
|
||||||
.map { _ in newKeyPair }
|
|
||||||
.eraseToAnyPublisher()
|
distributingKeyPairs.mutate {
|
||||||
|
$0[closedGroup.id] = ($0[closedGroup.id] ?? [])
|
||||||
|
.appending(newKeyPair)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sendData: MessageSender.PreparedSendData = try MessageSender
|
||||||
|
.preparedSendData(
|
||||||
|
db,
|
||||||
|
message: ClosedGroupControlMessage(
|
||||||
|
kind: .encryptionKeyPair(
|
||||||
|
publicKey: nil,
|
||||||
|
wrappers: targetMembers.map { memberPublicKey in
|
||||||
|
ClosedGroupControlMessage.KeyPairWrapper(
|
||||||
|
publicKey: memberPublicKey,
|
||||||
|
encryptedKeyPair: try MessageSender.encryptWithSessionProtocol(
|
||||||
|
db,
|
||||||
|
plaintext: plaintext,
|
||||||
|
for: memberPublicKey
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
to: try Message.Destination
|
||||||
|
.from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup),
|
||||||
|
namespace: try Message.Destination
|
||||||
|
.from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup)
|
||||||
|
.defaultNamespace,
|
||||||
|
interactionId: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
return (newKeyPair, sendData)
|
||||||
|
}
|
||||||
|
.flatMap { newKeyPair, sendData -> AnyPublisher<ClosedGroupKeyPair, Error> in
|
||||||
|
MessageSender.sendImmediate(preparedSendData: sendData)
|
||||||
|
.map { _ in newKeyPair }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
.handleEvents(
|
.handleEvents(
|
||||||
receiveOutput: { newKeyPair in
|
receiveOutput: { newKeyPair in
|
||||||
/// Store it **after** having sent out the message to the group
|
/// Store it **after** having sent out the message to the group
|
||||||
|
@ -253,116 +249,110 @@ extension MessageSender {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(
|
public static func update(
|
||||||
_ db: Database,
|
|
||||||
groupPublicKey: String,
|
groupPublicKey: String,
|
||||||
with members: Set<String>,
|
with members: Set<String>,
|
||||||
name: String
|
name: String
|
||||||
) -> AnyPublisher<Void, Error> {
|
) -> AnyPublisher<Void, Error> {
|
||||||
// Get the group, check preconditions & prepare
|
return Storage.shared
|
||||||
guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else {
|
.writePublisher { db -> (String, ClosedGroup, [GroupMember], Set<String>) in
|
||||||
SNLog("Can't update nonexistent closed group.")
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
return Fail(error: MessageSenderError.noThread)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: groupPublicKey) else {
|
|
||||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
|
||||||
|
|
||||||
do {
|
|
||||||
// Update name if needed
|
|
||||||
if name != closedGroup.name {
|
|
||||||
// Update the group
|
|
||||||
_ = try ClosedGroup
|
|
||||||
.filter(id: closedGroup.id)
|
|
||||||
.updateAll(db, ClosedGroup.Columns.name.set(to: name))
|
|
||||||
|
|
||||||
// Notify the user
|
// Get the group, check preconditions & prepare
|
||||||
let interaction: Interaction = try Interaction(
|
guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else {
|
||||||
threadId: groupPublicKey,
|
SNLog("Can't update nonexistent closed group.")
|
||||||
authorId: userPublicKey,
|
throw MessageSenderError.noThread
|
||||||
variant: .infoClosedGroupUpdated,
|
}
|
||||||
body: ClosedGroupControlMessage.Kind
|
guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: groupPublicKey) else {
|
||||||
.nameChange(name: name)
|
throw MessageSenderError.invalidClosedGroupUpdate
|
||||||
.infoMessage(db, sender: userPublicKey),
|
}
|
||||||
timestampMs: SnodeAPI.currentOffsetTimestampMs()
|
|
||||||
).inserted(db)
|
|
||||||
|
|
||||||
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
// Update name if needed
|
||||||
|
if name != closedGroup.name {
|
||||||
|
// Update the group
|
||||||
|
_ = try ClosedGroup
|
||||||
|
.filter(id: closedGroup.id)
|
||||||
|
.updateAll(db, ClosedGroup.Columns.name.set(to: name))
|
||||||
|
|
||||||
|
// Notify the user
|
||||||
|
let interaction: Interaction = try Interaction(
|
||||||
|
threadId: groupPublicKey,
|
||||||
|
authorId: userPublicKey,
|
||||||
|
variant: .infoClosedGroupUpdated,
|
||||||
|
body: ClosedGroupControlMessage.Kind
|
||||||
|
.nameChange(name: name)
|
||||||
|
.infoMessage(db, sender: userPublicKey),
|
||||||
|
timestampMs: SnodeAPI.currentOffsetTimestampMs()
|
||||||
|
).inserted(db)
|
||||||
|
|
||||||
|
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
||||||
|
|
||||||
|
// Send the update to the group
|
||||||
|
try MessageSender.send(
|
||||||
|
db,
|
||||||
|
message: ClosedGroupControlMessage(kind: .nameChange(name: name)),
|
||||||
|
interactionId: interactionId,
|
||||||
|
threadId: groupPublicKey,
|
||||||
|
threadVariant: .legacyGroup
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update libSession
|
||||||
|
try? SessionUtil.update(
|
||||||
|
db,
|
||||||
|
groupPublicKey: closedGroup.threadId,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Send the update to the group
|
// Retrieve member info
|
||||||
try MessageSender.send(
|
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
|
||||||
db,
|
throw MessageSenderError.invalidClosedGroupUpdate
|
||||||
message: ClosedGroupControlMessage(kind: .nameChange(name: name)),
|
}
|
||||||
interactionId: interactionId,
|
|
||||||
threadId: groupPublicKey,
|
|
||||||
threadVariant: .legacyGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update libSession
|
let standardAndZombieMemberIds: [String] = allGroupMembers
|
||||||
try? SessionUtil.update(
|
.filter { $0.role == .standard || $0.role == .zombie }
|
||||||
db,
|
.map { $0.profileId }
|
||||||
groupPublicKey: closedGroup.threadId,
|
let addedMembers: Set<String> = members.subtracting(standardAndZombieMemberIds)
|
||||||
name: name
|
|
||||||
|
// Add members if needed
|
||||||
|
if !addedMembers.isEmpty {
|
||||||
|
do {
|
||||||
|
try addMembers(
|
||||||
|
db,
|
||||||
|
addedMembers: addedMembers,
|
||||||
|
userPublicKey: userPublicKey,
|
||||||
|
allGroupMembers: allGroupMembers,
|
||||||
|
closedGroup: closedGroup
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw MessageSenderError.invalidClosedGroupUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove members if needed
|
||||||
|
return (
|
||||||
|
userPublicKey,
|
||||||
|
closedGroup,
|
||||||
|
allGroupMembers,
|
||||||
|
Set(standardAndZombieMemberIds).subtracting(members)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.flatMap { userPublicKey, closedGroup, allGroupMembers, removedMembers -> AnyPublisher<Void, Error> in
|
||||||
catch {
|
guard !removedMembers.isEmpty else {
|
||||||
return Fail(error: error)
|
return Just(())
|
||||||
.eraseToAnyPublisher()
|
.setFailureType(to: Error.self)
|
||||||
}
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
// Retrieve member info
|
|
||||||
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
|
return removeMembers(
|
||||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
let standardAndZombieMemberIds: [String] = allGroupMembers
|
|
||||||
.filter { $0.role == .standard || $0.role == .zombie }
|
|
||||||
.map { $0.profileId }
|
|
||||||
let addedMembers: Set<String> = members.subtracting(standardAndZombieMemberIds)
|
|
||||||
|
|
||||||
// Add members if needed
|
|
||||||
if !addedMembers.isEmpty {
|
|
||||||
do {
|
|
||||||
try addMembers(
|
|
||||||
db,
|
|
||||||
addedMembers: addedMembers,
|
|
||||||
userPublicKey: userPublicKey,
|
|
||||||
allGroupMembers: allGroupMembers,
|
|
||||||
closedGroup: closedGroup
|
|
||||||
)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove members if needed
|
|
||||||
let removedMembers: Set<String> = Set(standardAndZombieMemberIds).subtracting(members)
|
|
||||||
|
|
||||||
if !removedMembers.isEmpty {
|
|
||||||
do {
|
|
||||||
return try removeMembers(
|
|
||||||
db,
|
|
||||||
removedMembers: removedMembers,
|
removedMembers: removedMembers,
|
||||||
userPublicKey: userPublicKey,
|
userPublicKey: userPublicKey,
|
||||||
allGroupMembers: allGroupMembers,
|
allGroupMembers: allGroupMembers,
|
||||||
closedGroup: closedGroup
|
closedGroup: closedGroup
|
||||||
)
|
)
|
||||||
|
.catch { _ in Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
catch {
|
|
||||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Just(())
|
|
||||||
.setFailureType(to: Error.self)
|
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -476,19 +466,20 @@ extension MessageSender {
|
||||||
/// The returned promise is fulfilled when the `MEMBERS_REMOVED` message has been sent to the group AND the new encryption key pair has been
|
/// The returned promise is fulfilled when the `MEMBERS_REMOVED` message has been sent to the group AND the new encryption key pair has been
|
||||||
/// generated and distributed.
|
/// generated and distributed.
|
||||||
private static func removeMembers(
|
private static func removeMembers(
|
||||||
_ db: Database,
|
|
||||||
removedMembers: Set<String>,
|
removedMembers: Set<String>,
|
||||||
userPublicKey: String,
|
userPublicKey: String,
|
||||||
allGroupMembers: [GroupMember],
|
allGroupMembers: [GroupMember],
|
||||||
closedGroup: ClosedGroup
|
closedGroup: ClosedGroup
|
||||||
) throws -> AnyPublisher<Void, Error> {
|
) -> AnyPublisher<Void, Error> {
|
||||||
guard !removedMembers.contains(userPublicKey) else {
|
guard !removedMembers.contains(userPublicKey) else {
|
||||||
SNLog("Invalid closed group update.")
|
SNLog("Invalid closed group update.")
|
||||||
throw MessageSenderError.invalidClosedGroupUpdate
|
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else {
|
guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else {
|
||||||
SNLog("Only an admin can remove members from a group.")
|
SNLog("Only an admin can remove members from a group.")
|
||||||
throw MessageSenderError.invalidClosedGroupUpdate
|
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
let groupMemberIds: [String] = allGroupMembers
|
let groupMemberIds: [String] = allGroupMembers
|
||||||
|
@ -499,39 +490,39 @@ extension MessageSender {
|
||||||
.map { $0.profileId }
|
.map { $0.profileId }
|
||||||
let members: Set<String> = Set(groupMemberIds).subtracting(removedMembers)
|
let members: Set<String> = Set(groupMemberIds).subtracting(removedMembers)
|
||||||
|
|
||||||
// Update zombie & member list
|
return Storage.shared
|
||||||
try GroupMember
|
.writePublisher { db in
|
||||||
.filter(GroupMember.Columns.groupId == closedGroup.threadId)
|
// Update zombie & member list
|
||||||
.filter(removedMembers.contains(GroupMember.Columns.profileId))
|
try GroupMember
|
||||||
.filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role))
|
.filter(GroupMember.Columns.groupId == closedGroup.threadId)
|
||||||
.deleteAll(db)
|
.filter(removedMembers.contains(GroupMember.Columns.profileId))
|
||||||
|
.filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role))
|
||||||
let interactionId: Int64?
|
.deleteAll(db)
|
||||||
|
|
||||||
// Notify the user if needed (not if only zombie members were removed)
|
let interactionId: Int64?
|
||||||
if !removedMembers.subtracting(groupZombieIds).isEmpty {
|
|
||||||
let interaction: Interaction = try Interaction(
|
// Notify the user if needed (not if only zombie members were removed)
|
||||||
threadId: closedGroup.threadId,
|
if !removedMembers.subtracting(groupZombieIds).isEmpty {
|
||||||
authorId: userPublicKey,
|
let interaction: Interaction = try Interaction(
|
||||||
variant: .infoClosedGroupUpdated,
|
threadId: closedGroup.threadId,
|
||||||
body: ClosedGroupControlMessage.Kind
|
authorId: userPublicKey,
|
||||||
.membersRemoved(members: removedMembers.map { Data(hex: $0) })
|
variant: .infoClosedGroupUpdated,
|
||||||
.infoMessage(db, sender: userPublicKey),
|
body: ClosedGroupControlMessage.Kind
|
||||||
timestampMs: SnodeAPI.currentOffsetTimestampMs()
|
.membersRemoved(members: removedMembers.map { Data(hex: $0) })
|
||||||
).inserted(db)
|
.infoMessage(db, sender: userPublicKey),
|
||||||
|
timestampMs: SnodeAPI.currentOffsetTimestampMs()
|
||||||
guard let newInteractionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
).inserted(db)
|
||||||
|
|
||||||
interactionId = newInteractionId
|
guard let newInteractionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
||||||
}
|
|
||||||
else {
|
interactionId = newInteractionId
|
||||||
interactionId = nil
|
}
|
||||||
}
|
else {
|
||||||
|
interactionId = nil
|
||||||
// Send the update to the group and generate + distribute a new encryption key pair
|
}
|
||||||
return MessageSender
|
|
||||||
.sendImmediate(
|
// Send the update to the group and generate + distribute a new encryption key pair
|
||||||
preparedSendData: try MessageSender
|
return try MessageSender
|
||||||
.preparedSendData(
|
.preparedSendData(
|
||||||
db,
|
db,
|
||||||
message: ClosedGroupControlMessage(
|
message: ClosedGroupControlMessage(
|
||||||
|
@ -546,18 +537,15 @@ extension MessageSender {
|
||||||
.defaultNamespace,
|
.defaultNamespace,
|
||||||
interactionId: interactionId
|
interactionId: interactionId
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||||
.flatMap { _ -> AnyPublisher<Void, Error> in
|
.flatMap { _ -> AnyPublisher<Void, Error> in
|
||||||
Storage.shared
|
MessageSender.generateAndSendNewEncryptionKeyPair(
|
||||||
.writePublisherFlatMap { db in
|
targetMembers: members,
|
||||||
generateAndSendNewEncryptionKeyPair(
|
userPublicKey: userPublicKey,
|
||||||
db,
|
allGroupMembers: allGroupMembers,
|
||||||
targetMembers: members,
|
closedGroup: closedGroup
|
||||||
userPublicKey: userPublicKey,
|
)
|
||||||
allGroupMembers: allGroupMembers,
|
|
||||||
closedGroup: closedGroup
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,9 @@ import Foundation
|
||||||
import GRDB
|
import GRDB
|
||||||
|
|
||||||
public protocol NotificationsProtocol {
|
public protocol NotificationsProtocol {
|
||||||
func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread)
|
func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State)
|
||||||
func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread)
|
func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State)
|
||||||
func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread)
|
func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State)
|
||||||
func cancelNotifications(identifiers: [String])
|
func cancelNotifications(identifiers: [String])
|
||||||
func clearAllNotifications()
|
func clearAllNotifications()
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,38 +57,47 @@ extension OpenGroupAPI {
|
||||||
) {
|
) {
|
||||||
guard hasStarted else { return }
|
guard hasStarted else { return }
|
||||||
|
|
||||||
let minPollFailureCount: TimeInterval = Storage.shared
|
dependencies.storage
|
||||||
.read { db in
|
.readPublisher { [server = server] db in
|
||||||
try OpenGroup
|
try OpenGroup
|
||||||
.filter(OpenGroup.Columns.server == server)
|
.filter(OpenGroup.Columns.server == server)
|
||||||
.select(min(OpenGroup.Columns.pollFailureCount))
|
.select(min(OpenGroup.Columns.pollFailureCount))
|
||||||
.asRequest(of: TimeInterval.self)
|
.asRequest(of: TimeInterval.self)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
}
|
}
|
||||||
.defaulting(to: 0)
|
.tryFlatMap { [weak self] minPollFailureCount -> AnyPublisher<(TimeInterval, TimeInterval), Error> in
|
||||||
let lastPollStart: TimeInterval = Date().timeIntervalSince1970
|
guard let strongSelf = self else { throw OpenGroupAPIError.invalidPoll }
|
||||||
let nextPollInterval: TimeInterval = getInterval(for: minPollFailureCount, minInterval: Poller.minPollInterval, maxInterval: Poller.maxPollInterval)
|
|
||||||
|
let lastPollStart: TimeInterval = Date().timeIntervalSince1970
|
||||||
// Wait until the last poll completes before polling again ensuring we don't poll any faster than
|
let nextPollInterval: TimeInterval = Poller.getInterval(
|
||||||
// the 'nextPollInterval' value
|
for: (minPollFailureCount ?? 0),
|
||||||
poll(using: dependencies)
|
minInterval: Poller.minPollInterval,
|
||||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
maxInterval: Poller.maxPollInterval
|
||||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
)
|
||||||
|
|
||||||
|
// Wait until the last poll completes before polling again ensuring we don't poll any faster than
|
||||||
|
// the 'nextPollInterval' value
|
||||||
|
return strongSelf.poll(using: dependencies)
|
||||||
|
.map { _ in (lastPollStart, nextPollInterval) }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.subscribe(on: dependencies.subscribeQueue)
|
||||||
|
.receive(on: dependencies.receiveQueue)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveCompletion: { [weak self] _ in
|
receiveValue: { [weak self] lastPollStart, nextPollInterval in
|
||||||
let currentTime: TimeInterval = Date().timeIntervalSince1970
|
let currentTime: TimeInterval = Date().timeIntervalSince1970
|
||||||
let remainingInterval: TimeInterval = max(0, nextPollInterval - (currentTime - lastPollStart))
|
let remainingInterval: TimeInterval = max(0, nextPollInterval - (currentTime - lastPollStart))
|
||||||
|
|
||||||
guard remainingInterval > 0 else {
|
guard remainingInterval > 0 else {
|
||||||
return Threading.pollerQueue.async {
|
return dependencies.subscribeQueue.async {
|
||||||
self?.pollRecursively(using: dependencies)
|
self?.pollRecursively(using: dependencies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self?.timer = Timer.scheduledTimerOnMainThread(withTimeInterval: remainingInterval, repeats: false) { timer in
|
self?.timer = Timer.scheduledTimerOnMainThread(withTimeInterval: remainingInterval, repeats: false) { timer in
|
||||||
timer.invalidate()
|
timer.invalidate()
|
||||||
|
|
||||||
Threading.pollerQueue.async {
|
dependencies.subscribeQueue.async {
|
||||||
self?.pollRecursively(using: dependencies)
|
self?.pollRecursively(using: dependencies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -120,6 +129,11 @@ extension OpenGroupAPI {
|
||||||
|
|
||||||
self.isPolling = true
|
self.isPolling = true
|
||||||
let server: String = self.server
|
let server: String = self.server
|
||||||
|
let hasPerformedInitialPoll: Bool = (dependencies.cache.hasPerformedInitialPoll[server] == true)
|
||||||
|
let timeSinceLastPoll: TimeInterval = (
|
||||||
|
dependencies.cache.timeSinceLastPoll[server] ??
|
||||||
|
dependencies.mutableCache.mutate { $0.getTimeSinceLastOpen(using: dependencies) }
|
||||||
|
)
|
||||||
|
|
||||||
return dependencies.storage
|
return dependencies.storage
|
||||||
.readPublisher { db -> (Int64, PreparedSendData<BatchResponse>) in
|
.readPublisher { db -> (Int64, PreparedSendData<BatchResponse>) in
|
||||||
|
@ -136,11 +150,8 @@ extension OpenGroupAPI {
|
||||||
.preparedPoll(
|
.preparedPoll(
|
||||||
db,
|
db,
|
||||||
server: server,
|
server: server,
|
||||||
hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true,
|
hasPerformedInitialPoll: hasPerformedInitialPoll,
|
||||||
timeSinceLastPoll: (
|
timeSinceLastPoll: timeSinceLastPoll,
|
||||||
dependencies.cache.timeSinceLastPoll[server] ??
|
|
||||||
dependencies.cache.getTimeSinceLastOpen(using: dependencies)
|
|
||||||
),
|
|
||||||
using: dependencies
|
using: dependencies
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -591,12 +602,12 @@ extension OpenGroupAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// MARK: - Convenience
|
||||||
// MARK: - Convenience
|
|
||||||
|
|
||||||
fileprivate static func getInterval(for failureCount: TimeInterval, minInterval: TimeInterval, maxInterval: TimeInterval) -> TimeInterval {
|
fileprivate static func getInterval(for failureCount: TimeInterval, minInterval: TimeInterval, maxInterval: TimeInterval) -> TimeInterval {
|
||||||
// Arbitrary backoff factor...
|
// Arbitrary backoff factor...
|
||||||
return min(maxInterval, minInterval + pow(2, failureCount))
|
return min(maxInterval, minInterval + pow(2, failureCount))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,7 +93,6 @@ public class Poller {
|
||||||
let namespaces: [SnodeAPI.Namespace] = self.namespaces
|
let namespaces: [SnodeAPI.Namespace] = self.namespaces
|
||||||
|
|
||||||
getSnodeForPolling(for: publicKey)
|
getSnodeForPolling(for: publicKey)
|
||||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
|
||||||
.flatMap { snode -> AnyPublisher<[Message], Error> in
|
.flatMap { snode -> AnyPublisher<[Message], Error> in
|
||||||
Poller.poll(
|
Poller.poll(
|
||||||
namespaces: namespaces,
|
namespaces: namespaces,
|
||||||
|
@ -103,7 +102,8 @@ public class Poller {
|
||||||
using: dependencies
|
using: dependencies
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
.subscribe(on: dependencies.subscribeQueue)
|
||||||
|
.receive(on: dependencies.receiveQueue)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveCompletion: { [weak self] result in
|
receiveCompletion: { [weak self] result in
|
||||||
switch result {
|
switch result {
|
||||||
|
@ -134,7 +134,6 @@ public class Poller {
|
||||||
timer.invalidate()
|
timer.invalidate()
|
||||||
|
|
||||||
self?.getSnodeForPolling(for: publicKey)
|
self?.getSnodeForPolling(for: publicKey)
|
||||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
|
||||||
.flatMap { snode -> AnyPublisher<[Message], Error> in
|
.flatMap { snode -> AnyPublisher<[Message], Error> in
|
||||||
Poller.poll(
|
Poller.poll(
|
||||||
namespaces: namespaces,
|
namespaces: namespaces,
|
||||||
|
@ -144,7 +143,8 @@ public class Poller {
|
||||||
using: dependencies
|
using: dependencies
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
.subscribe(on: dependencies.subscribeQueue)
|
||||||
|
.receive(on: dependencies.receiveQueue)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveCompletion: { result in
|
receiveCompletion: { result in
|
||||||
switch result {
|
switch result {
|
||||||
|
|
|
@ -139,7 +139,7 @@ internal extension SessionUtil {
|
||||||
|
|
||||||
// Add any new communities (via the OpenGroupManager)
|
// Add any new communities (via the OpenGroupManager)
|
||||||
communities.forEach { community in
|
communities.forEach { community in
|
||||||
OpenGroupManager.shared
|
let successfullyAddedGroup: Bool = OpenGroupManager.shared
|
||||||
.add(
|
.add(
|
||||||
db,
|
db,
|
||||||
roomToken: community.data.roomToken,
|
roomToken: community.data.roomToken,
|
||||||
|
@ -147,7 +147,20 @@ internal extension SessionUtil {
|
||||||
publicKey: community.data.publicKey,
|
publicKey: community.data.publicKey,
|
||||||
calledFromConfigHandling: true
|
calledFromConfigHandling: true
|
||||||
)
|
)
|
||||||
.sinkUntilComplete()
|
|
||||||
|
if successfullyAddedGroup {
|
||||||
|
db.afterNextTransactionNested { _ in
|
||||||
|
OpenGroupManager.shared.performInitialRequestsAfterAdd(
|
||||||
|
successfullyAddedGroup: successfullyAddedGroup,
|
||||||
|
roomToken: community.data.roomToken,
|
||||||
|
server: community.data.server,
|
||||||
|
publicKey: community.data.publicKey,
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
|
.subscribe(on: OpenGroupAPI.workQueue)
|
||||||
|
.sinkUntilComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set the priority if it's changed (new communities will have already been inserted at
|
// Set the priority if it's changed (new communities will have already been inserted at
|
||||||
// this stage)
|
// this stage)
|
||||||
|
|
|
@ -403,40 +403,35 @@ public enum SessionUtil {
|
||||||
guard SessionUtil.userConfigsEnabled else { return [] }
|
guard SessionUtil.userConfigsEnabled else { return [] }
|
||||||
|
|
||||||
return Storage.shared
|
return Storage.shared
|
||||||
.read { db -> [String] in
|
.read { db -> Set<ConfigDump.Variant> in
|
||||||
guard Identity.userExists(db) else { return [] }
|
guard Identity.userExists(db) else { return [] }
|
||||||
|
|
||||||
let existingDumpVariants: Set<ConfigDump.Variant> = (try? ConfigDump
|
return try ConfigDump
|
||||||
.select(.variant)
|
.select(.variant)
|
||||||
.filter(ConfigDump.Columns.publicKey == publicKey)
|
.filter(ConfigDump.Columns.publicKey == publicKey)
|
||||||
.asRequest(of: ConfigDump.Variant.self)
|
.asRequest(of: ConfigDump.Variant.self)
|
||||||
.fetchSet(db))
|
.fetchSet(db)
|
||||||
.defaulting(to: [])
|
|
||||||
|
|
||||||
/// Extract all existing hashes for any dumps associated with the given `publicKey`
|
|
||||||
return existingDumpVariants
|
|
||||||
.map { variant -> [String] in
|
|
||||||
guard
|
|
||||||
let conf = SessionUtil
|
|
||||||
.config(for: variant, publicKey: publicKey)
|
|
||||||
.wrappedValue,
|
|
||||||
let hashList: UnsafeMutablePointer<config_string_list> = config_current_hashes(conf)
|
|
||||||
else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: [String] = [String](
|
|
||||||
pointer: hashList.pointee.value,
|
|
||||||
count: hashList.pointee.len,
|
|
||||||
defaultValue: []
|
|
||||||
)
|
|
||||||
hashList.deallocate()
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
.reduce([], +)
|
|
||||||
}
|
}
|
||||||
.defaulting(to: [])
|
.defaulting(to: [])
|
||||||
|
.map { variant -> [String] in
|
||||||
|
/// Extract all existing hashes for any dumps associated with the given `publicKey`
|
||||||
|
guard
|
||||||
|
let conf = SessionUtil
|
||||||
|
.config(for: variant, publicKey: publicKey)
|
||||||
|
.wrappedValue,
|
||||||
|
let hashList: UnsafeMutablePointer<config_string_list> = config_current_hashes(conf)
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
let result: [String] = [String](
|
||||||
|
pointer: hashList.pointee.value,
|
||||||
|
count: hashList.pointee.len,
|
||||||
|
defaultValue: []
|
||||||
|
)
|
||||||
|
hashList.deallocate()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
.reduce([], +)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Receiving
|
// MARK: - Receiving
|
||||||
|
|
|
@ -24,7 +24,7 @@ public class DeviceSleepManager: NSObject {
|
||||||
return "SleepBlock(\(String(reflecting: blockObject)))"
|
return "SleepBlock(\(String(reflecting: blockObject)))"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(blockObject: NSObject) {
|
init(blockObject: NSObject?) {
|
||||||
self.blockObject = blockObject
|
self.blockObject = blockObject
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -51,14 +51,14 @@ public class DeviceSleepManager: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public func addBlock(blockObject: NSObject) {
|
public func addBlock(blockObject: NSObject?) {
|
||||||
blocks.append(SleepBlock(blockObject: blockObject))
|
blocks.append(SleepBlock(blockObject: blockObject))
|
||||||
|
|
||||||
ensureSleepBlocking()
|
ensureSleepBlocking()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public func removeBlock(blockObject: NSObject) {
|
public func removeBlock(blockObject: NSObject?) {
|
||||||
blocks = blocks.filter {
|
blocks = blocks.filter {
|
||||||
$0.blockObject != nil && $0.blockObject != blockObject
|
$0.blockObject != nil && $0.blockObject != blockObject
|
||||||
}
|
}
|
||||||
|
|
|
@ -498,7 +498,7 @@ public struct ProfileManager {
|
||||||
dependencies: Dependencies = Dependencies()
|
dependencies: Dependencies = Dependencies()
|
||||||
) throws {
|
) throws {
|
||||||
let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies))
|
let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies))
|
||||||
let profile: Profile = Profile.fetchOrCreate(id: publicKey)
|
let profile: Profile = Profile.fetchOrCreate(db, id: publicKey)
|
||||||
var profileChanges: [ConfigColumnAssignment] = []
|
var profileChanges: [ConfigColumnAssignment] = []
|
||||||
|
|
||||||
// Name
|
// Name
|
||||||
|
|
|
@ -118,9 +118,9 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
dependencies = OpenGroupManager.OGMDependencies(
|
dependencies = OpenGroupManager.OGMDependencies(
|
||||||
subscribeQueue: DispatchQueue.main,
|
subscribeQueue: DispatchQueue.main,
|
||||||
receiveQueue: DispatchQueue.main,
|
receiveQueue: DispatchQueue.main,
|
||||||
cache: Atomic(mockOGMCache),
|
cache: mockOGMCache,
|
||||||
onionApi: TestCapabilitiesAndRoomApi.self,
|
onionApi: TestCapabilitiesAndRoomApi.self,
|
||||||
generalCache: Atomic(mockGeneralCache),
|
generalCache: mockGeneralCache,
|
||||||
storage: mockStorage,
|
storage: mockStorage,
|
||||||
sodium: mockSodium,
|
sodium: mockSodium,
|
||||||
genericHash: mockGenericHash,
|
genericHash: mockGenericHash,
|
||||||
|
@ -367,7 +367,11 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
|
|
||||||
mockOGMCache.when { $0.hasPerformedInitialPoll }.thenReturn([:])
|
mockOGMCache.when { $0.hasPerformedInitialPoll }.thenReturn([:])
|
||||||
mockOGMCache.when { $0.timeSinceLastPoll }.thenReturn([:])
|
mockOGMCache.when { $0.timeSinceLastPoll }.thenReturn([:])
|
||||||
mockOGMCache.when { $0.getTimeSinceLastOpen(using: dependencies) }.thenReturn(0)
|
mockOGMCache
|
||||||
|
.when { [dependencies = dependencies!] cache in
|
||||||
|
cache.getTimeSinceLastOpen(using: dependencies)
|
||||||
|
}
|
||||||
|
.thenReturn(0)
|
||||||
mockOGMCache.when { $0.isPolling }.thenReturn(false)
|
mockOGMCache.when { $0.isPolling }.thenReturn(false)
|
||||||
mockOGMCache.when { $0.pollers }.thenReturn([:])
|
mockOGMCache.when { $0.pollers }.thenReturn([:])
|
||||||
|
|
||||||
|
@ -816,7 +820,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
var didComplete: Bool = false // Prevent multi-threading test bugs
|
var didComplete: Bool = false // Prevent multi-threading test bugs
|
||||||
|
|
||||||
mockStorage
|
mockStorage
|
||||||
.writePublisherFlatMap { (db: Database) -> AnyPublisher<Void, Error> in
|
.writePublisher { (db: Database) -> Bool in
|
||||||
openGroupManager
|
openGroupManager
|
||||||
.add(
|
.add(
|
||||||
db,
|
db,
|
||||||
|
@ -827,6 +831,16 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
dependencies: dependencies
|
dependencies: dependencies
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.flatMap { successfullyAddedGroup in
|
||||||
|
openGroupManager.performInitialRequestsAfterAdd(
|
||||||
|
successfullyAddedGroup: successfullyAddedGroup,
|
||||||
|
roomToken: "testRoom",
|
||||||
|
server: "testServer",
|
||||||
|
publicKey: TestConstants.serverPublicKey,
|
||||||
|
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||||
|
dependencies: dependencies
|
||||||
|
)
|
||||||
|
}
|
||||||
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -847,7 +861,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
var didComplete: Bool = false // Prevent multi-threading test bugs
|
var didComplete: Bool = false // Prevent multi-threading test bugs
|
||||||
|
|
||||||
mockStorage
|
mockStorage
|
||||||
.writePublisherFlatMap { (db: Database) -> AnyPublisher<Void, Error> in
|
.writePublisher { (db: Database) -> Bool in
|
||||||
openGroupManager
|
openGroupManager
|
||||||
.add(
|
.add(
|
||||||
db,
|
db,
|
||||||
|
@ -858,6 +872,16 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
dependencies: dependencies
|
dependencies: dependencies
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.flatMap { successfullyAddedGroup in
|
||||||
|
openGroupManager.performInitialRequestsAfterAdd(
|
||||||
|
successfullyAddedGroup: successfullyAddedGroup,
|
||||||
|
roomToken: "testRoom",
|
||||||
|
server: "testServer",
|
||||||
|
publicKey: TestConstants.serverPublicKey,
|
||||||
|
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||||
|
dependencies: dependencies
|
||||||
|
)
|
||||||
|
}
|
||||||
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -884,7 +908,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
var didComplete: Bool = false // Prevent multi-threading test bugs
|
var didComplete: Bool = false // Prevent multi-threading test bugs
|
||||||
|
|
||||||
mockStorage
|
mockStorage
|
||||||
.writePublisherFlatMap { (db: Database) -> AnyPublisher<Void, Error> in
|
.writePublisher { (db: Database) -> Bool in
|
||||||
openGroupManager
|
openGroupManager
|
||||||
.add(
|
.add(
|
||||||
db,
|
db,
|
||||||
|
@ -897,6 +921,18 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
dependencies: dependencies
|
dependencies: dependencies
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.flatMap { successfullyAddedGroup in
|
||||||
|
openGroupManager.performInitialRequestsAfterAdd(
|
||||||
|
successfullyAddedGroup: successfullyAddedGroup,
|
||||||
|
roomToken: "testRoom",
|
||||||
|
server: "testServer",
|
||||||
|
publicKey: TestConstants.serverPublicKey
|
||||||
|
.replacingOccurrences(of: "c3", with: "00")
|
||||||
|
.replacingOccurrences(of: "b3", with: "00"),
|
||||||
|
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||||
|
dependencies: dependencies
|
||||||
|
)
|
||||||
|
}
|
||||||
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -940,7 +976,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
var error: Error?
|
var error: Error?
|
||||||
|
|
||||||
mockStorage
|
mockStorage
|
||||||
.writePublisherFlatMap { (db: Database) -> AnyPublisher<Void, Error> in
|
.writePublisher { (db: Database) -> Bool in
|
||||||
openGroupManager
|
openGroupManager
|
||||||
.add(
|
.add(
|
||||||
db,
|
db,
|
||||||
|
@ -951,6 +987,16 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
dependencies: dependencies
|
dependencies: dependencies
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.flatMap { successfullyAddedGroup in
|
||||||
|
openGroupManager.performInitialRequestsAfterAdd(
|
||||||
|
successfullyAddedGroup: successfullyAddedGroup,
|
||||||
|
roomToken: "testRoom",
|
||||||
|
server: "testServer",
|
||||||
|
publicKey: TestConstants.serverPublicKey,
|
||||||
|
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||||
|
dependencies: dependencies
|
||||||
|
)
|
||||||
|
}
|
||||||
.mapError { result -> Error in error.setting(to: result) }
|
.mapError { result -> Error in error.setting(to: result) }
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -3334,15 +3380,16 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
upload: false,
|
upload: false,
|
||||||
defaultUpload: nil
|
defaultUpload: nil
|
||||||
)
|
)
|
||||||
let publisher = Future<[OpenGroupAPI.Room], Error> { resolver in
|
let publisher = Future<[OpenGroupManager.DefaultRoomInfo], Error> { resolver in
|
||||||
resolver(Result.success([uniqueRoomInstance]))
|
resolver(Result.success([(uniqueRoomInstance, nil)]))
|
||||||
}
|
}
|
||||||
.shareReplay(1)
|
.shareReplay(1)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
mockOGMCache.when { $0.defaultRoomsPublisher }.thenReturn(publisher)
|
mockOGMCache.when { $0.defaultRoomsPublisher }.thenReturn(publisher)
|
||||||
let publisher2 = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies)
|
let publisher2 = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies)
|
||||||
|
|
||||||
expect(publisher2.firstValue()).to(equal(publisher.firstValue()))
|
expect(publisher2.firstValue()?.map { $0.room })
|
||||||
|
.to(equal(publisher.firstValue()?.map { $0.room }))
|
||||||
}
|
}
|
||||||
|
|
||||||
it("stores the open group information") {
|
it("stores the open group information") {
|
||||||
|
@ -3376,13 +3423,13 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
it("fetches rooms for the server") {
|
it("fetches rooms for the server") {
|
||||||
var response: [OpenGroupAPI.Room]?
|
var response: [OpenGroupManager.DefaultRoomInfo]?
|
||||||
|
|
||||||
OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies)
|
OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies)
|
||||||
.handleEvents(receiveOutput: { (data: [OpenGroupAPI.Room]) in response = data })
|
.handleEvents(receiveOutput: { response = $0 })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
expect(response)
|
expect(response?.map { $0.room })
|
||||||
.toEventually(
|
.toEventually(
|
||||||
equal(
|
equal(
|
||||||
[
|
[
|
||||||
|
@ -3598,17 +3645,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
.thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher])
|
.thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher])
|
||||||
|
|
||||||
var result: Data?
|
var result: Data?
|
||||||
mockStorage
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: "testServer",
|
||||||
fileId: "1",
|
existingData: nil,
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: "testServer",
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.handleEvents(receiveOutput: { result = $0 })
|
.handleEvents(receiveOutput: { result = $0 })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -3617,17 +3661,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
|
|
||||||
it("does not save the fetched image to storage") {
|
it("does not save the fetched image to storage") {
|
||||||
var didComplete: Bool = false
|
var didComplete: Bool = false
|
||||||
mockStorage
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: "testServer",
|
||||||
fileId: "1",
|
existingData: nil,
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: "testServer",
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -3648,17 +3689,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
|
|
||||||
it("does not update the image update timestamp") {
|
it("does not update the image update timestamp") {
|
||||||
var didComplete: Bool = false
|
var didComplete: Bool = false
|
||||||
mockStorage
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: "testServer",
|
||||||
fileId: "1",
|
existingData: nil,
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: "testServer",
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -3690,17 +3728,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
}
|
}
|
||||||
dependencies = dependencies.with(onionApi: TestNeverReturningApi.self)
|
dependencies = dependencies.with(onionApi: TestNeverReturningApi.self)
|
||||||
|
|
||||||
let publisher = mockStorage
|
let publisher = OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: "testServer",
|
||||||
fileId: "1",
|
existingData: nil,
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: "testServer",
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
publisher.sinkAndStore(in: &disposables)
|
publisher.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
expect(mockOGMCache)
|
expect(mockOGMCache)
|
||||||
|
@ -3716,17 +3751,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
it("fetches a new image if there is no cached one") {
|
it("fetches a new image if there is no cached one") {
|
||||||
var result: Data?
|
var result: Data?
|
||||||
|
|
||||||
mockStorage
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: OpenGroupAPI.defaultServer,
|
||||||
fileId: "1",
|
existingData: nil,
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: OpenGroupAPI.defaultServer,
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.handleEvents(receiveOutput: { (data: Data) in result = data })
|
.handleEvents(receiveOutput: { (data: Data) in result = data })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -3736,17 +3768,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
it("saves the fetched image to storage") {
|
it("saves the fetched image to storage") {
|
||||||
var didComplete: Bool = false
|
var didComplete: Bool = false
|
||||||
|
|
||||||
mockStorage
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: OpenGroupAPI.defaultServer,
|
||||||
fileId: "1",
|
existingData: nil,
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: OpenGroupAPI.defaultServer,
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -3768,17 +3797,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
it("updates the image update timestamp") {
|
it("updates the image update timestamp") {
|
||||||
var didComplete: Bool = false
|
var didComplete: Bool = false
|
||||||
|
|
||||||
mockStorage
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: OpenGroupAPI.defaultServer,
|
||||||
fileId: "1",
|
existingData: nil,
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: OpenGroupAPI.defaultServer,
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
.handleEvents(receiveCompletion: { _ in didComplete = true })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -3816,17 +3842,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
it("retrieves the cached image") {
|
it("retrieves the cached image") {
|
||||||
var result: Data?
|
var result: Data?
|
||||||
|
|
||||||
mockStorage
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: OpenGroupAPI.defaultServer,
|
||||||
fileId: "1",
|
existingData: Data([2, 3, 4]),
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: OpenGroupAPI.defaultServer,
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.handleEvents(receiveOutput: { (data: Data) in result = data })
|
.handleEvents(receiveOutput: { (data: Data) in result = data })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
@ -3846,17 +3869,14 @@ class OpenGroupManagerSpec: QuickSpec {
|
||||||
|
|
||||||
var result: Data?
|
var result: Data?
|
||||||
|
|
||||||
mockStorage
|
OpenGroupManager
|
||||||
.readPublisherFlatMap { (db: Database) -> AnyPublisher<Data, Error> in
|
.roomImage(
|
||||||
OpenGroupManager
|
fileId: "1",
|
||||||
.roomImage(
|
for: "testRoom",
|
||||||
db,
|
on: OpenGroupAPI.defaultServer,
|
||||||
fileId: "1",
|
existingData: Data([2, 3, 4]),
|
||||||
for: "testRoom",
|
using: dependencies
|
||||||
on: OpenGroupAPI.defaultServer,
|
)
|
||||||
using: dependencies
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.handleEvents(receiveOutput: { (data: Data) in result = data })
|
.handleEvents(receiveOutput: { (data: Data) in result = data })
|
||||||
.sinkAndStore(in: &disposables)
|
.sinkAndStore(in: &disposables)
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import SessionUtilitiesKit
|
||||||
extension SMKDependencies {
|
extension SMKDependencies {
|
||||||
public func with(
|
public func with(
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: MutableGeneralCacheType? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
scheduler: ValueObservationScheduler? = nil,
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
sodium: SodiumType? = nil,
|
sodium: SodiumType? = nil,
|
||||||
|
@ -26,7 +26,7 @@ extension SMKDependencies {
|
||||||
) -> SMKDependencies {
|
) -> SMKDependencies {
|
||||||
return SMKDependencies(
|
return SMKDependencies(
|
||||||
onionApi: (onionApi ?? self._onionApi.wrappedValue),
|
onionApi: (onionApi ?? self._onionApi.wrappedValue),
|
||||||
generalCache: (generalCache ?? self._generalCache.wrappedValue),
|
generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue),
|
||||||
storage: (storage ?? self._storage.wrappedValue),
|
storage: (storage ?? self._storage.wrappedValue),
|
||||||
scheduler: (scheduler ?? self._scheduler.wrappedValue),
|
scheduler: (scheduler ?? self._scheduler.wrappedValue),
|
||||||
sodium: (sodium ?? self._sodium.wrappedValue),
|
sodium: (sodium ?? self._sodium.wrappedValue),
|
||||||
|
|
|
@ -6,9 +6,9 @@ import SessionUtilitiesKit
|
||||||
|
|
||||||
@testable import SessionMessagingKit
|
@testable import SessionMessagingKit
|
||||||
|
|
||||||
class MockOGMCache: Mock<OGMCacheType>, OGMCacheType {
|
class MockOGMCache: Mock<OGMMutableCacheType>, OGMMutableCacheType {
|
||||||
var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>? {
|
var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? {
|
||||||
get { return accept() as? AnyPublisher<[OpenGroupAPI.Room], Error> }
|
get { return accept() as? AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> }
|
||||||
set { accept(args: [newValue]) }
|
set { accept(args: [newValue]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,9 +9,9 @@ import SessionUtilitiesKit
|
||||||
|
|
||||||
extension OpenGroupManager.OGMDependencies {
|
extension OpenGroupManager.OGMDependencies {
|
||||||
public func with(
|
public func with(
|
||||||
cache: Atomic<OGMCacheType>? = nil,
|
cache: OGMMutableCacheType? = nil,
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: MutableGeneralCacheType? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
scheduler: ValueObservationScheduler? = nil,
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
sodium: SodiumType? = nil,
|
sodium: SodiumType? = nil,
|
||||||
|
@ -28,7 +28,7 @@ extension OpenGroupManager.OGMDependencies {
|
||||||
return OpenGroupManager.OGMDependencies(
|
return OpenGroupManager.OGMDependencies(
|
||||||
cache: (cache ?? self._mutableCache.wrappedValue),
|
cache: (cache ?? self._mutableCache.wrappedValue),
|
||||||
onionApi: (onionApi ?? self._onionApi.wrappedValue),
|
onionApi: (onionApi ?? self._onionApi.wrappedValue),
|
||||||
generalCache: (generalCache ?? self._generalCache.wrappedValue),
|
generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue),
|
||||||
storage: (storage ?? self._storage.wrappedValue),
|
storage: (storage ?? self._storage.wrappedValue),
|
||||||
scheduler: (scheduler ?? self._scheduler.wrappedValue),
|
scheduler: (scheduler ?? self._scheduler.wrappedValue),
|
||||||
sodium: (sodium ?? self._sodium.wrappedValue),
|
sodium: (sodium ?? self._sodium.wrappedValue),
|
||||||
|
|
|
@ -9,7 +9,7 @@ import SessionMessagingKit
|
||||||
public class NSENotificationPresenter: NSObject, NotificationsProtocol {
|
public class NSENotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
private var notifications: [String: UNNotificationRequest] = [:]
|
private var notifications: [String: UNNotificationRequest] = [:]
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) {
|
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) {
|
||||||
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
||||||
|
|
||||||
// Ensure we should be showing a notification for the thread
|
// Ensure we should be showing a notification for the thread
|
||||||
|
@ -124,7 +124,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) {
|
public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) {
|
||||||
// No call notifications for muted or group threads
|
// No call notifications for muted or group threads
|
||||||
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
||||||
guard
|
guard
|
||||||
|
@ -180,7 +180,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) {
|
public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) {
|
||||||
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
||||||
|
|
||||||
// No reaction notifications for muted, group threads or message requests
|
// No reaction notifications for muted, group threads or message requests
|
||||||
|
|
|
@ -158,7 +158,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
||||||
.notifyUser(
|
.notifyUser(
|
||||||
db,
|
db,
|
||||||
forIncomingCall: interaction,
|
forIncomingCall: interaction,
|
||||||
in: thread
|
in: thread,
|
||||||
|
applicationState: .background
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,9 @@ import SessionMessagingKit
|
||||||
|
|
||||||
final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate {
|
final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate {
|
||||||
private let viewModel: ThreadPickerViewModel = ThreadPickerViewModel()
|
private let viewModel: ThreadPickerViewModel = ThreadPickerViewModel()
|
||||||
private var dataChangeObservable: DatabaseCancellable?
|
private var dataChangeObservable: DatabaseCancellable? {
|
||||||
|
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
||||||
|
}
|
||||||
private var hasLoadedInitialData: Bool = false
|
private var hasLoadedInitialData: Bool = false
|
||||||
|
|
||||||
var shareNavController: ShareNavController?
|
var shareNavController: ShareNavController?
|
||||||
|
@ -79,8 +81,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||||
|
@ -91,8 +92,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Layout
|
// MARK: Layout
|
||||||
|
@ -104,6 +104,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
||||||
// MARK: - Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
private func startObservingChanges() {
|
private func startObservingChanges() {
|
||||||
|
guard dataChangeObservable == nil else { return }
|
||||||
|
|
||||||
// Start observing for data changes
|
// Start observing for data changes
|
||||||
dataChangeObservable = Storage.shared.start(
|
dataChangeObservable = Storage.shared.start(
|
||||||
viewModel.observableViewData,
|
viewModel.observableViewData,
|
||||||
|
@ -115,6 +117,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stopObservingChanges() {
|
||||||
|
dataChangeObservable = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) {
|
private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) {
|
||||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||||
// in from a frame of CGRect.zero)
|
// in from a frame of CGRect.zero)
|
||||||
|
@ -154,7 +160,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
||||||
|
|
||||||
ShareNavController.attachmentPrepPublisher?
|
ShareNavController.attachmentPrepPublisher?
|
||||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: DispatchQueue.main)
|
||||||
.sinkUntilComplete(
|
.sinkUntilComplete(
|
||||||
receiveValue: { [weak self] attachments in
|
receiveValue: { [weak self] attachments in
|
||||||
guard let strongSelf = self else { return }
|
guard let strongSelf = self else { return }
|
||||||
|
|
|
@ -17,7 +17,7 @@ open class SSKDependencies: Dependencies {
|
||||||
subscribeQueue: DispatchQueue? = nil,
|
subscribeQueue: DispatchQueue? = nil,
|
||||||
receiveQueue: DispatchQueue? = nil,
|
receiveQueue: DispatchQueue? = nil,
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: MutableGeneralCacheType? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
scheduler: ValueObservationScheduler? = nil,
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
standardUserDefaults: UserDefaultsType? = nil,
|
standardUserDefaults: UserDefaultsType? = nil,
|
||||||
|
|
|
@ -51,7 +51,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
|
||||||
)
|
)
|
||||||
cancellables.append(
|
cancellables.append(
|
||||||
viewModel.observableTableData
|
viewModel.observableTableData
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { viewModel.updateTableData($0.0) }
|
receiveValue: { viewModel.updateTableData($0.0) }
|
||||||
|
@ -162,7 +162,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
|
||||||
)
|
)
|
||||||
cancellables.append(
|
cancellables.append(
|
||||||
viewModel.observableTableData
|
viewModel.observableTableData
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { viewModel.updateTableData($0.0) }
|
receiveValue: { viewModel.updateTableData($0.0) }
|
||||||
|
@ -241,7 +241,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
|
||||||
|
|
||||||
cancellables.append(
|
cancellables.append(
|
||||||
viewModel.rightNavItems
|
viewModel.rightNavItems
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { info in footerButtonInfo = info }
|
receiveValue: { info in footerButtonInfo = info }
|
||||||
|
@ -347,7 +347,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
cancellables.append(
|
cancellables.append(
|
||||||
viewModel.rightNavItems
|
viewModel.rightNavItems
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { info in footerButtonInfo = info }
|
receiveValue: { info in footerButtonInfo = info }
|
||||||
|
@ -382,7 +382,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
|
||||||
|
|
||||||
cancellables.append(
|
cancellables.append(
|
||||||
viewModel.dismissScreen
|
viewModel.dismissScreen
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { _ in didDismissScreen = true }
|
receiveValue: { _ in didDismissScreen = true }
|
||||||
|
|
|
@ -35,7 +35,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
|
||||||
)
|
)
|
||||||
mockGeneralCache = MockGeneralCache()
|
mockGeneralCache = MockGeneralCache()
|
||||||
dependencies = Dependencies(
|
dependencies = Dependencies(
|
||||||
generalCache: Atomic(mockGeneralCache),
|
generalCache: mockGeneralCache,
|
||||||
storage: mockStorage,
|
storage: mockStorage,
|
||||||
scheduler: .immediate
|
scheduler: .immediate
|
||||||
)
|
)
|
||||||
|
@ -75,7 +75,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
|
||||||
)
|
)
|
||||||
disposables.append(
|
disposables.append(
|
||||||
viewModel.observableTableData
|
viewModel.observableTableData
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { viewModel.updateTableData($0.0) }
|
receiveValue: { viewModel.updateTableData($0.0) }
|
||||||
|
@ -173,7 +173,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
|
||||||
)
|
)
|
||||||
disposables.append(
|
disposables.append(
|
||||||
viewModel.observableTableData
|
viewModel.observableTableData
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { viewModel.updateTableData($0.0) }
|
receiveValue: { viewModel.updateTableData($0.0) }
|
||||||
|
@ -447,7 +447,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
|
||||||
)
|
)
|
||||||
disposables.append(
|
disposables.append(
|
||||||
viewModel.observableTableData
|
viewModel.observableTableData
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { viewModel.updateTableData($0.0) }
|
receiveValue: { viewModel.updateTableData($0.0) }
|
||||||
|
@ -489,7 +489,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
|
||||||
)
|
)
|
||||||
disposables.append(
|
disposables.append(
|
||||||
viewModel.observableTableData
|
viewModel.observableTableData
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { viewModel.updateTableData($0.0) }
|
receiveValue: { viewModel.updateTableData($0.0) }
|
||||||
|
|
|
@ -31,7 +31,7 @@ class NotificationContentViewModelSpec: QuickSpec {
|
||||||
)
|
)
|
||||||
viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate)
|
viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate)
|
||||||
dataChangeCancellable = viewModel.observableTableData
|
dataChangeCancellable = viewModel.observableTableData
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { viewModel.updateTableData($0.0) }
|
receiveValue: { viewModel.updateTableData($0.0) }
|
||||||
|
@ -99,7 +99,7 @@ class NotificationContentViewModelSpec: QuickSpec {
|
||||||
}
|
}
|
||||||
viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate)
|
viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate)
|
||||||
dataChangeCancellable = viewModel.observableTableData
|
dataChangeCancellable = viewModel.observableTableData
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { viewModel.updateTableData($0.0) }
|
receiveValue: { viewModel.updateTableData($0.0) }
|
||||||
|
@ -148,7 +148,7 @@ class NotificationContentViewModelSpec: QuickSpec {
|
||||||
var didDismissScreen: Bool = false
|
var didDismissScreen: Bool = false
|
||||||
|
|
||||||
dismissCancellable = viewModel.dismissScreen
|
dismissCancellable = viewModel.dismissScreen
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { _ in didDismissScreen = true }
|
receiveValue: { _ in didDismissScreen = true }
|
||||||
|
|
|
@ -25,64 +25,6 @@ public extension Publisher {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The standard `.subscribe(on: DispatchQueue.main)` seems to ocassionally dispatch to the
|
|
||||||
/// next run loop before actually subscribing, this method checks if it's running on the main thread already and
|
|
||||||
/// if so just subscribes directly rather than routing via `.receive(on:)`
|
|
||||||
func subscribe<S>(
|
|
||||||
on scheduler: S,
|
|
||||||
immediatelyIfMain: Bool,
|
|
||||||
options: S.SchedulerOptions? = nil
|
|
||||||
) -> AnyPublisher<Output, Failure> where S: Scheduler {
|
|
||||||
guard immediatelyIfMain && ((scheduler as? DispatchQueue) == DispatchQueue.main) else {
|
|
||||||
return self.subscribe(on: scheduler, options: options)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
return self
|
|
||||||
.flatMap { value -> AnyPublisher<Output, Failure> in
|
|
||||||
guard Thread.isMainThread else {
|
|
||||||
return Just(value)
|
|
||||||
.setFailureType(to: Failure.self)
|
|
||||||
.subscribe(on: scheduler, options: options)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
return Just(value)
|
|
||||||
.setFailureType(to: Failure.self)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The standard `.receive(on: DispatchQueue.main)` seems to ocassionally dispatch to the
|
|
||||||
/// next run loop before emitting data, this method checks if it's running on the main thread already and
|
|
||||||
/// if so just emits directly rather than routing via `.receive(on:)`
|
|
||||||
func receive<S>(
|
|
||||||
on scheduler: S,
|
|
||||||
immediatelyIfMain: Bool,
|
|
||||||
options: S.SchedulerOptions? = nil
|
|
||||||
) -> AnyPublisher<Output, Failure> where S: Scheduler {
|
|
||||||
guard immediatelyIfMain && ((scheduler as? DispatchQueue) == DispatchQueue.main) else {
|
|
||||||
return self.receive(on: scheduler, options: options)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
return self
|
|
||||||
.flatMap { value -> AnyPublisher<Output, Failure> in
|
|
||||||
guard Thread.isMainThread else {
|
|
||||||
return Just(value)
|
|
||||||
.setFailureType(to: Failure.self)
|
|
||||||
.receive(on: scheduler, options: options)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
return Just(value)
|
|
||||||
.setFailureType(to: Failure.self)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func tryFlatMap<T, P>(
|
func tryFlatMap<T, P>(
|
||||||
maxPublishers: Subscribers.Demand = .unlimited,
|
maxPublishers: Subscribers.Demand = .unlimited,
|
||||||
_ transform: @escaping (Self.Output) throws -> P
|
_ transform: @escaping (Self.Output) throws -> P
|
||||||
|
|
|
@ -9,8 +9,7 @@ public final class ReplaySubject<Output, Failure: Error>: Subject {
|
||||||
private var buffer: [Output] = [Output]()
|
private var buffer: [Output] = [Output]()
|
||||||
private let bufferSize: Int
|
private let bufferSize: Int
|
||||||
private let lock: NSRecursiveLock = NSRecursiveLock()
|
private let lock: NSRecursiveLock = NSRecursiveLock()
|
||||||
|
private var subscriptions: Atomic<[ReplaySubjectSubscription<Output, Failure>]> = Atomic([])
|
||||||
private var subscriptions = [ReplaySubjectSubscription<Output, Failure>]()
|
|
||||||
private var completion: Subscribers.Completion<Failure>?
|
private var completion: Subscribers.Completion<Failure>?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
@ -27,7 +26,7 @@ public final class ReplaySubject<Output, Failure: Error>: Subject {
|
||||||
|
|
||||||
buffer.append(value)
|
buffer.append(value)
|
||||||
buffer = buffer.suffix(bufferSize)
|
buffer = buffer.suffix(bufferSize)
|
||||||
subscriptions.forEach { $0.receive(value) }
|
subscriptions.wrappedValue.forEach { $0.receive(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a completion signal to the subscriber
|
/// Sends a completion signal to the subscriber
|
||||||
|
@ -35,7 +34,7 @@ public final class ReplaySubject<Output, Failure: Error>: Subject {
|
||||||
lock.lock(); defer { lock.unlock() }
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
subscriptions.forEach { subscription in subscription.receive(completion: completion) }
|
subscriptions.wrappedValue.forEach { $0.receive(completion: completion) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provides this Subject an opportunity to establish demand for any new upstream subscriptions
|
/// Provides this Subject an opportunity to establish demand for any new upstream subscriptions
|
||||||
|
@ -49,10 +48,23 @@ public final class ReplaySubject<Output, Failure: Error>: Subject {
|
||||||
public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
|
public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
|
||||||
lock.lock(); defer { lock.unlock() }
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
|
||||||
let subscription = ReplaySubjectSubscription<Output, Failure>(downstream: AnySubscriber(subscriber))
|
/// According to the below comment the `subscriber.receive(subscription: subscription)` code runs asynchronously
|
||||||
|
/// which aligns with testing (resulting in the `request(_ newDemand: Subscribers.Demand)` function getting called after this
|
||||||
|
/// function returns
|
||||||
|
///
|
||||||
|
/// Later in the thread it's mentioned that as of `iOS 13.3` this behaviour changed to be synchronous but as of writing the minimum
|
||||||
|
/// deployment version is set to `iOS 13.0` which I assume is why we are seeing the async behaviour which results in `receiveValue`
|
||||||
|
/// not being called in some cases
|
||||||
|
///
|
||||||
|
/// When the project is eventually updated to have a minimum version higher than `iOS 13.3` we should re-test this behaviour to see if
|
||||||
|
/// we can revert this change
|
||||||
|
///
|
||||||
|
/// https://forums.swift.org/t/combine-receive-on-runloop-main-loses-sent-value-how-can-i-make-it-work/28631/20
|
||||||
|
let subscription: ReplaySubjectSubscription = ReplaySubjectSubscription<Output, Failure>(downstream: AnySubscriber(subscriber)) { [weak self, buffer = buffer, completion = completion] subscription in
|
||||||
|
self?.subscriptions.mutate { $0.append(subscription) }
|
||||||
|
subscription.replay(buffer, completion: completion)
|
||||||
|
}
|
||||||
subscriber.receive(subscription: subscription)
|
subscriber.receive(subscription: subscription)
|
||||||
subscriptions.append(subscription)
|
|
||||||
subscription.replay(buffer, completion: completion)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,17 +74,21 @@ public final class ReplaySubjectSubscription<Output, Failure: Error>: Subscripti
|
||||||
private let downstream: AnySubscriber<Output, Failure>
|
private let downstream: AnySubscriber<Output, Failure>
|
||||||
private var isCompleted: Bool = false
|
private var isCompleted: Bool = false
|
||||||
private var demand: Subscribers.Demand = .none
|
private var demand: Subscribers.Demand = .none
|
||||||
|
private var onInitialDemand: ((ReplaySubjectSubscription) -> ())?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(downstream: AnySubscriber<Output, Failure>) {
|
init(downstream: AnySubscriber<Output, Failure>, onInitialDemand: @escaping (ReplaySubjectSubscription) -> ()) {
|
||||||
self.downstream = downstream
|
self.downstream = downstream
|
||||||
|
self.onInitialDemand = onInitialDemand
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subscription
|
// MARK: - Subscription
|
||||||
|
|
||||||
public func request(_ newDemand: Subscribers.Demand) {
|
public func request(_ newDemand: Subscribers.Demand) {
|
||||||
demand += newDemand
|
demand += newDemand
|
||||||
|
onInitialDemand?(self)
|
||||||
|
onInitialDemand = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func cancel() {
|
public func cancel() {
|
||||||
|
|
|
@ -361,10 +361,26 @@ open class Storage {
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
|
private static func logIfNeeded(_ error: Error, isWrite: Bool) {
|
||||||
|
switch error {
|
||||||
|
case DatabaseError.SQLITE_ABORT:
|
||||||
|
let message: String = ((error as? DatabaseError)?.message ?? "Unknown")
|
||||||
|
SNLog("[Storage] Database \(isWrite ? "write" : "read") failed due to error: \(message)")
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func logIfNeeded<T>(_ error: Error, isWrite: Bool) -> T? {
|
||||||
|
logIfNeeded(error, isWrite: isWrite)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult public final func write<T>(updates: (Database) throws -> T?) -> T? {
|
@discardableResult public final func write<T>(updates: (Database) throws -> T?) -> T? {
|
||||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
||||||
|
|
||||||
return try? dbWriter.write(updates)
|
do { return try dbWriter.write(updates) }
|
||||||
|
catch { return Storage.logIfNeeded(error, isWrite: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
open func writeAsync<T>(updates: @escaping (Database) throws -> T) {
|
open func writeAsync<T>(updates: @escaping (Database) throws -> T) {
|
||||||
|
@ -377,6 +393,11 @@ open class Storage {
|
||||||
dbWriter.asyncWrite(
|
dbWriter.asyncWrite(
|
||||||
updates,
|
updates,
|
||||||
completion: { db, result in
|
completion: { db, result in
|
||||||
|
switch result {
|
||||||
|
case .failure(let error): Storage.logIfNeeded(error, isWrite: true)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
try? completion(db, result)
|
try? completion(db, result)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -400,7 +421,10 @@ open class Storage {
|
||||||
return Deferred {
|
return Deferred {
|
||||||
Future { resolver in
|
Future { resolver in
|
||||||
do { resolver(Result.success(try dbWriter.write(updates))) }
|
do { resolver(Result.success(try dbWriter.write(updates))) }
|
||||||
catch { resolver(Result.failure(error)) }
|
catch {
|
||||||
|
Storage.logIfNeeded(error, isWrite: true)
|
||||||
|
resolver(Result.failure(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.eraseToAnyPublisher()
|
}.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
@ -423,7 +447,10 @@ open class Storage {
|
||||||
return Deferred {
|
return Deferred {
|
||||||
Future { resolver in
|
Future { resolver in
|
||||||
do { resolver(Result.success(try dbWriter.read(value))) }
|
do { resolver(Result.success(try dbWriter.read(value))) }
|
||||||
catch { resolver(Result.failure(error)) }
|
catch {
|
||||||
|
Storage.logIfNeeded(error, isWrite: false)
|
||||||
|
resolver(Result.failure(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.eraseToAnyPublisher()
|
}.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
@ -431,7 +458,8 @@ open class Storage {
|
||||||
@discardableResult public final func read<T>(_ value: (Database) throws -> T?) -> T? {
|
@discardableResult public final func read<T>(_ value: (Database) throws -> T?) -> T? {
|
||||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
||||||
|
|
||||||
return try? dbWriter.read(value)
|
do { return try dbWriter.read(value) }
|
||||||
|
catch { return Storage.logIfNeeded(error, isWrite: false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rever to the `ValueObservation.start` method for full documentation
|
/// Rever to the `ValueObservation.start` method for full documentation
|
||||||
|
@ -488,24 +516,6 @@ open class Storage {
|
||||||
|
|
||||||
// MARK: - Combine Extensions
|
// MARK: - Combine Extensions
|
||||||
|
|
||||||
public extension Storage {
|
|
||||||
func readPublisherFlatMap<T>(
|
|
||||||
value: @escaping (Database) throws -> AnyPublisher<T, Error>
|
|
||||||
) -> AnyPublisher<T, Error> {
|
|
||||||
return readPublisher(value: value)
|
|
||||||
.flatMap { resultPublisher -> AnyPublisher<T, Error> in resultPublisher }
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func writePublisherFlatMap<T>(
|
|
||||||
updates: @escaping (Database) throws -> AnyPublisher<T, Error>
|
|
||||||
) -> AnyPublisher<T, Error> {
|
|
||||||
return writePublisher(updates: updates)
|
|
||||||
.flatMap { resultPublisher -> AnyPublisher<T, Error> in resultPublisher }
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension ValueObservation {
|
public extension ValueObservation {
|
||||||
func publisher(
|
func publisher(
|
||||||
in storage: Storage,
|
in storage: Storage,
|
||||||
|
|
|
@ -4,6 +4,10 @@ import Foundation
|
||||||
import GRDB
|
import GRDB
|
||||||
|
|
||||||
open class Dependencies {
|
open class Dependencies {
|
||||||
|
/// These should not be accessed directly but rather via an instance of this type
|
||||||
|
private static let _generalCacheInstance: MutableGeneralCacheType = General.Cache()
|
||||||
|
private static let _generalCacheInstanceAccessQueue = DispatchQueue(label: "GeneralCacheInstanceAccess")
|
||||||
|
|
||||||
public var _subscribeQueue: Atomic<DispatchQueue?>
|
public var _subscribeQueue: Atomic<DispatchQueue?>
|
||||||
public var subscribeQueue: DispatchQueue {
|
public var subscribeQueue: DispatchQueue {
|
||||||
get { Dependencies.getValueSettingIfNull(&_subscribeQueue) { DispatchQueue.global(qos: .default) } }
|
get { Dependencies.getValueSettingIfNull(&_subscribeQueue) { DispatchQueue.global(qos: .default) } }
|
||||||
|
@ -16,10 +20,25 @@ open class Dependencies {
|
||||||
set { _receiveQueue.mutate { $0 = newValue } }
|
set { _receiveQueue.mutate { $0 = newValue } }
|
||||||
}
|
}
|
||||||
|
|
||||||
public var _generalCache: Atomic<Atomic<GeneralCacheType>?>
|
public var _mutableGeneralCache: Atomic<MutableGeneralCacheType?>
|
||||||
public var generalCache: Atomic<GeneralCacheType> {
|
public var mutableGeneralCache: Atomic<MutableGeneralCacheType> {
|
||||||
get { Dependencies.getValueSettingIfNull(&_generalCache) { General.cache } }
|
get {
|
||||||
set { _generalCache.mutate { $0 = newValue } }
|
Dependencies.getMutableValueSettingIfNull(&_mutableGeneralCache) {
|
||||||
|
Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public var generalCache: GeneralCacheType {
|
||||||
|
get {
|
||||||
|
Dependencies.getValueSettingIfNull(&_mutableGeneralCache) {
|
||||||
|
Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let mutableValue: MutableGeneralCacheType = newValue as? MutableGeneralCacheType else { return }
|
||||||
|
|
||||||
|
_mutableGeneralCache.mutate { $0 = mutableValue }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var _storage: Atomic<Storage?>
|
public var _storage: Atomic<Storage?>
|
||||||
|
@ -51,7 +70,7 @@ open class Dependencies {
|
||||||
public init(
|
public init(
|
||||||
subscribeQueue: DispatchQueue? = nil,
|
subscribeQueue: DispatchQueue? = nil,
|
||||||
receiveQueue: DispatchQueue? = nil,
|
receiveQueue: DispatchQueue? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: MutableGeneralCacheType? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
scheduler: ValueObservationScheduler? = nil,
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
standardUserDefaults: UserDefaultsType? = nil,
|
standardUserDefaults: UserDefaultsType? = nil,
|
||||||
|
@ -59,7 +78,7 @@ open class Dependencies {
|
||||||
) {
|
) {
|
||||||
_subscribeQueue = Atomic(subscribeQueue)
|
_subscribeQueue = Atomic(subscribeQueue)
|
||||||
_receiveQueue = Atomic(receiveQueue)
|
_receiveQueue = Atomic(receiveQueue)
|
||||||
_generalCache = Atomic(generalCache)
|
_mutableGeneralCache = Atomic(generalCache)
|
||||||
_storage = Atomic(storage)
|
_storage = Atomic(storage)
|
||||||
_scheduler = Atomic(scheduler)
|
_scheduler = Atomic(scheduler)
|
||||||
_standardUserDefaults = Atomic(standardUserDefaults)
|
_standardUserDefaults = Atomic(standardUserDefaults)
|
||||||
|
@ -77,4 +96,14 @@ open class Dependencies {
|
||||||
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func getMutableValueSettingIfNull<T>(_ maybeValue: inout Atomic<T?>, _ valueGenerator: () -> T) -> Atomic<T> {
|
||||||
|
guard let value: T = maybeValue.wrappedValue else {
|
||||||
|
let value: T = valueGenerator()
|
||||||
|
maybeValue.mutate { $0 = value }
|
||||||
|
return Atomic(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Atomic(value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,35 +3,48 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import GRDB
|
import GRDB
|
||||||
|
|
||||||
public protocol GeneralCacheType {
|
// MARK: - General.Cache
|
||||||
var encodedPublicKey: String? { get set }
|
|
||||||
var recentReactionTimestamps: [Int64] { get set }
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum General {
|
public enum General {
|
||||||
public class Cache: GeneralCacheType {
|
public class Cache: MutableGeneralCacheType {
|
||||||
public var encodedPublicKey: String? = nil
|
public var encodedPublicKey: String? = nil
|
||||||
public var recentReactionTimestamps: [Int64] = []
|
public var recentReactionTimestamps: [Int64] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var cache: Atomic<GeneralCacheType> = Atomic(Cache())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - GeneralError
|
||||||
|
|
||||||
public enum GeneralError: Error {
|
public enum GeneralError: Error {
|
||||||
case invalidSeed
|
case invalidSeed
|
||||||
case keyGenerationFailed
|
case keyGenerationFailed
|
||||||
case randomGenerationFailed
|
case randomGenerationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience
|
||||||
|
|
||||||
public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> String {
|
public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> String {
|
||||||
if let cachedKey: String = dependencies.generalCache.wrappedValue.encodedPublicKey { return cachedKey }
|
if let cachedKey: String = dependencies.generalCache.encodedPublicKey { return cachedKey }
|
||||||
|
|
||||||
if let publicKey: Data = Identity.fetchUserPublicKey(db) { // Can be nil under some circumstances
|
if let publicKey: Data = Identity.fetchUserPublicKey(db) { // Can be nil under some circumstances
|
||||||
let sessionId: SessionId = SessionId(.standard, publicKey: publicKey.bytes)
|
let sessionId: SessionId = SessionId(.standard, publicKey: publicKey.bytes)
|
||||||
|
|
||||||
dependencies.generalCache.mutate { $0.encodedPublicKey = sessionId.hexString }
|
dependencies.mutableGeneralCache.mutate { $0.encodedPublicKey = sessionId.hexString }
|
||||||
return sessionId.hexString
|
return sessionId.hexString
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - GeneralCacheType
|
||||||
|
|
||||||
|
public protocol MutableGeneralCacheType: GeneralCacheType {
|
||||||
|
var encodedPublicKey: String? { get set }
|
||||||
|
var recentReactionTimestamps: [Int64] { get set }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a
|
||||||
|
/// non-thread-safe way
|
||||||
|
public protocol GeneralCacheType {
|
||||||
|
var encodedPublicKey: String? { get }
|
||||||
|
var recentReactionTimestamps: [Int64] { get }
|
||||||
|
}
|
||||||
|
|
|
@ -90,12 +90,10 @@ public enum AppSetup {
|
||||||
// method when calling within a database read/write closure)
|
// method when calling within a database read/write closure)
|
||||||
Storage.shared.read { db in SessionUtil.refreshingUserConfigsEnabled(db) }
|
Storage.shared.read { db in SessionUtil.refreshingUserConfigsEnabled(db) }
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync))
|
||||||
migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync))
|
|
||||||
|
// The 'if' is only there to prevent the "variable never read" warning from showing
|
||||||
// The 'if' is only there to prevent the "variable never read" warning from showing
|
if backgroundTask != nil { backgroundTask = nil }
|
||||||
if backgroundTask != nil { backgroundTask = nil }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,15 +8,15 @@ import SignalCoreKit
|
||||||
public class NoopNotificationsManager: NotificationsProtocol {
|
public class NoopNotificationsManager: NotificationsProtocol {
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) {
|
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) {
|
||||||
owsFailDebug("")
|
owsFailDebug("")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) {
|
public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) {
|
||||||
owsFailDebug("")
|
owsFailDebug("")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) {
|
public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) {
|
||||||
owsFailDebug("")
|
owsFailDebug("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,8 @@ import SessionUtilitiesKit
|
||||||
public extension Publisher {
|
public extension Publisher {
|
||||||
func sinkAndStore<C>(in storage: inout C) where C: RangeReplaceableCollection, C.Element == AnyCancellable {
|
func sinkAndStore<C>(in storage: inout C) where C: RangeReplaceableCollection, C.Element == AnyCancellable {
|
||||||
self
|
self
|
||||||
.subscribe(on: DispatchQueue.main, immediatelyIfMain: true)
|
.subscribe(on: ImmediateScheduler.shared)
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { _ in }
|
receiveValue: { _ in }
|
||||||
|
@ -22,7 +22,7 @@ public extension AnyPublisher {
|
||||||
var value: Output?
|
var value: Output?
|
||||||
|
|
||||||
_ = self
|
_ = self
|
||||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { _ in },
|
receiveCompletion: { _ in },
|
||||||
receiveValue: { result in value = result }
|
receiveValue: { result in value = result }
|
||||||
|
|
|
@ -5,7 +5,7 @@ import SessionUtilitiesKit
|
||||||
|
|
||||||
@testable import SessionMessagingKit
|
@testable import SessionMessagingKit
|
||||||
|
|
||||||
class MockGeneralCache: Mock<GeneralCacheType>, GeneralCacheType {
|
class MockGeneralCache: Mock<MutableGeneralCacheType>, MutableGeneralCacheType {
|
||||||
var encodedPublicKey: String? {
|
var encodedPublicKey: String? {
|
||||||
get { return accept() as? String }
|
get { return accept() as? String }
|
||||||
set { accept(args: [newValue]) }
|
set { accept(args: [newValue]) }
|
||||||
|
|
Loading…
Reference in New Issue