Fixed up the remaining reported internal testing issues

Removed the 'readPublisherFlatMap/writePublisherFlatMap' functions as they easily resulted in behaviours which held up database threads
Tweaked the logic around starting the open group pollers to avoid an unlikely atomic lock blocks
Updated some logic to avoid accessing database read threads for longer than needed
Updated the OpenGroupManager to only update the 'seqNo' value for valid messages
Cleaned up some double Atomic wrapped instances which had some weird access behaviours
Fixed an issue where a database read thread could have been started within a database write thread
Fixed an issue where the ReplaySubject might not emit values in some cases
This commit is contained in:
Morgan Pretty 2023-06-28 18:03:40 +10:00
parent b6328f79b9
commit 6cf7cc42ab
53 changed files with 906 additions and 765 deletions

View File

@ -6417,7 +6417,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 410;
CURRENT_PROJECT_VERSION = 411;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6489,7 +6489,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 410;
CURRENT_PROJECT_VERSION = 411;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -6554,7 +6554,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 410;
CURRENT_PROJECT_VERSION = 411;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6628,7 +6628,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 410;
CURRENT_PROJECT_VERSION = 411;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -7536,7 +7536,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 410;
CURRENT_PROJECT_VERSION = 411;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -7607,7 +7607,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 410;
CURRENT_PROJECT_VERSION = 411;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",

View File

@ -246,11 +246,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
)
// Start the timeout timer for the call
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
.flatMap { _ in
Storage.shared.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
webRTCSession.sendOffer(db, to: sessionId)
}
}
.flatMap { _ in webRTCSession.sendOffer(to: thread) }
.sinkUntilComplete()
}
@ -431,10 +427,12 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
let sessionId: String = self.sessionId
let webRTCSession: WebRTCSession = self.webRTCSession
Storage.shared
.readPublisherFlatMap { db in
webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true)
}
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: sessionId) }) else {
return
}
webRTCSession
.sendOffer(to: thread, isRestartingICEConnection: true)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete()
}

View File

@ -114,7 +114,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
publicKey: call.sessionId,
threadVariant: .contact,
customImageData: nil,
profile: Profile.fetchOrCreate(id: call.sessionId),
profile: Storage.shared.read { db in Profile.fetchOrCreate(db, id: call.sessionId) },
additionalProfile: nil
)
displayNameLabel.text = call.contactName

View File

@ -464,21 +464,18 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
Storage.shared
.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
if !updatedMemberIds.contains(userPublicKey) {
try MessageSender.leave(
db,
groupPublicKey: threadId,
deleteThread: true
)
return Just(())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
return MessageSender.update(
.writePublisher { db in
guard updatedMemberIds.contains(userPublicKey) else { return }
try MessageSender.leave(
db,
groupPublicKey: threadId,
deleteThread: true
)
}
.flatMap {
MessageSender.update(
groupPublicKey: threadId,
with: updatedMemberIds,
name: updatedName

View File

@ -332,10 +332,8 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
let selectedContacts = self.selectedContacts
let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil)
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
Storage.shared
.writePublisherFlatMap { db in
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
}
MessageSender
.createClosedGroup(name: name, members: selectedContacts)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(

View File

@ -1181,7 +1181,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
self.viewModel.threadData.threadIsMessageRequest != true && (
cellViewModel.variant == .standardIncoming ||
@ -1193,7 +1198,7 @@ extension ConversationVC:
let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken
let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
let recentReactionTimestamps: [Int64] = General.cache.wrappedValue.recentReactionTimestamps
let recentReactionTimestamps: [Int64] = dependencies.generalCache.recentReactionTimestamps
guard
recentReactionTimestamps.count < 20 ||
@ -1211,7 +1216,7 @@ extension ConversationVC:
return
}
General.cache.mutate {
dependencies.mutableGeneralCache.mutate {
$0.recentReactionTimestamps = Array($0.recentReactionTimestamps
.suffix(19))
.appending(sentTimestamp)
@ -1522,7 +1527,7 @@ extension ConversationVC:
}
Storage.shared
.writePublisherFlatMap { db in
.writePublisher { db in
OpenGroupManager.shared.add(
db,
roomToken: room,
@ -1531,6 +1536,15 @@ extension ConversationVC:
calledFromConfigHandling: false
)
}
.flatMap { successfullyAddedGroup in
OpenGroupManager.shared.performInitialRequestsAfterAdd(
successfullyAddedGroup: successfullyAddedGroup,
roomToken: room,
server: server,
publicKey: publicKey,
calledFromConfigHandling: false
)
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(

View File

@ -63,6 +63,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
typealias InitialData = (
currentUserPublicKey: String,
initialUnreadInteractionInfo: Interaction.TimestampInfo?,
threadIsBlocked: Bool,
currentUserIsClosedGroupMember: Bool?,
@ -73,6 +74,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
// If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
// unread interaction and start focused around that one
@ -94,7 +96,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil :
GroupMember
.filter(groupMember[.groupId] == threadId)
.filter(groupMember[.profileId] == getUserHexEncodedPublicKey(db))
.filter(groupMember[.profileId] == currentUserPublicKey)
.filter(groupMember[.role] == GroupMember.Role.standard)
.isNotEmpty(db)
)
@ -112,6 +114,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
)
return (
currentUserPublicKey,
initialUnreadInteractionInfo,
threadIsBlocked,
currentUserIsClosedGroupMember,
@ -128,7 +131,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
self.threadData = SessionThreadViewModel(
threadId: threadId,
threadVariant: threadVariant,
threadIsNoteToSelf: (self.threadId == getUserHexEncodedPublicKey()),
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
threadIsBlocked: initialData?.threadIsBlocked,
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
openGroupPermissions: initialData?.openGroupPermissions
@ -141,7 +144,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// distinct stutter)
self.pagedDataObserver = self.setupPagedObserver(
for: threadId,
userPublicKey: getUserHexEncodedPublicKey(),
userPublicKey: (initialData?.currentUserPublicKey ?? getUserHexEncodedPublicKey()),
blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey(
threadId: threadId,
threadVariant: threadVariant

View File

@ -228,7 +228,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
// Preparation
SessionApp.homeViewController.mutate { $0 = self }
updateNavBarButtons()
updateNavBarButtons(userProfile: self.viewModel.state.userProfile)
setUpNavBarSessionHeading()
// Recovery phrase reminder
@ -382,7 +382,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
}
if updatedState.userProfile != self.viewModel.state.userProfile {
updateNavBarButtons()
updateNavBarButtons(userProfile: updatedState.userProfile)
}
// Update the 'view seed' UI
@ -489,17 +489,17 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
}
}
private func updateNavBarButtons() {
private func updateNavBarButtons(userProfile: Profile) {
// Profile picture view
let profilePictureView = ProfilePictureView(size: .navigation)
profilePictureView.accessibilityIdentifier = "User settings"
profilePictureView.accessibilityLabel = "User settings"
profilePictureView.isAccessibilityElement = true
profilePictureView.update(
publicKey: getUserHexEncodedPublicKey(),
publicKey: userProfile.id,
threadVariant: .contact,
customImageData: nil,
profile: Profile.fetchOrCreateCurrentUser(),
profile: userProfile,
additionalProfile: nil
)

View File

@ -26,32 +26,39 @@ public class HomeViewModel {
let showViewedSeedBanner: Bool
let hasHiddenMessageRequests: Bool
let unreadMessageRequestThreadCount: Int
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
}
let userProfile: Profile
}
// MARK: - Initialization
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
// 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
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
let userPublicKey: String = getUserHexEncodedPublicKey()
let userPublicKey: String = self.state.userProfile.id
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: SessionThread.self,

View File

@ -13,7 +13,7 @@ import SignalCoreKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 5
private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 10
var window: UIWindow?
var backgroundSnapshotBlockerWindow: UIWindow?
@ -73,7 +73,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
},
migrationsCompletion: { [weak self] result, needsConfigSync in
if case .failure(let error) = result {
self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .migrationError(error))
DispatchQueue.main.async {
self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .databaseError(error))
}
return
}
@ -150,7 +152,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
migrationsCompletion: { [weak self] result, needsConfigSync in
if case .failure(let error) = result {
DispatchQueue.main.async {
self?.showFailedStartupAlert(calledFrom: .enterForeground, error: .migrationError(error))
self?.showFailedStartupAlert(calledFrom: .enterForeground, error: .databaseError(error))
}
return
}
@ -372,8 +374,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
})
switch error {
// Don't offer the 'Restore' option if it was a 'startupFailed' error as a restore is unlikely to
// resolve it (most likely the database is locked or the key was somehow lost - safer to get them
// to restart and manually reinstall/restore)
case .databaseError(StorageError.startupFailed): break
// Offer the 'Restore' option if it was a migration error
case .migrationError:
case .databaseError:
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
if SUKLegacy.hasLegacyDatabaseFile {
// Remove the legacy database and any message hashes that have been migrated to the new DB
@ -402,7 +409,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
migrationsCompletion: { [weak self] result, needsConfigSync in
switch result {
case .failure:
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore)
DispatchQueue.main.async {
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore)
}
case .success:
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
@ -848,16 +857,15 @@ private enum LifecycleMethod {
// MARK: - StartupError
private enum StartupError: Error {
case databaseStartupError
case migrationError(Error)
case databaseError(Error)
case failedToRestore
case startupTimeout
var message: String {
switch self {
case .databaseStartupError: return "DATABASE_STARTUP_FAILED".localized()
case .databaseError(StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized()
case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized()
case .migrationError: return "DATABASE_MIGRATION_FAILED".localized()
case .databaseError: return "DATABASE_MIGRATION_FAILED".localized()
case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized()
}
}

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "متاسفانه خطایی رخ داده است";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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هشدار: بازیابی دستگاه شما منجر به از دست رفتن داده‌های قدیمی‌تر از دو هفته می‌شود.";
"RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
"RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oups, une erreur est survenue";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -414,10 +414,10 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"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";
"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 be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"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";
"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.";

View File

@ -169,7 +169,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC
ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in
Storage.shared
.writePublisherFlatMap { db in
.writePublisher { db in
OpenGroupManager.shared.add(
db,
roomToken: roomToken,
@ -178,6 +178,15 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC
calledFromConfigHandling: false
)
}
.flatMap { successfullyAddedGroup in
OpenGroupManager.shared.performInitialRequestsAfterAdd(
successfullyAddedGroup: successfullyAddedGroup,
roomToken: roomToken,
server: server,
publicKey: publicKey,
calledFromConfigHandling: false
)
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(

View File

@ -9,7 +9,7 @@ import SessionUIKit
final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2)
private var maxWidth: CGFloat
private var rooms: [OpenGroupAPI.Room] = [] { didSet { update() } }
private var data: [OpenGroupManager.DefaultRoomInfo] = [] { didSet { update() } }
private var heightConstraint: NSLayoutConstraint!
var delegate: OpenGroupSuggestionGridDelegate?
@ -146,8 +146,13 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
.subscribe(on: DispatchQueue.global(qos: .default))
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in self?.update() },
receiveValue: { [weak self] rooms in self?.rooms = rooms }
receiveCompletion: { [weak self] result in
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.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 height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing))
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
return CGSize(
width: Cell.calculatedWith(for: rooms[indexPath.item].name),
width: Cell.calculatedWith(for: data[indexPath.item].room.name),
height: OpenGroupSuggestionGrid.cellHeight
)
}
@ -192,12 +197,12 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
// MARK: - Data Source
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 {
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
}
@ -205,7 +210,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
// MARK: - Interaction
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)
}
}
@ -232,8 +237,6 @@ extension OpenGroupSuggestionGrid {
)
}
var room: OpenGroupAPI.Room? { didSet { update() } }
private lazy var snContentView: UIView = {
let result: UIView = UIView()
result.themeBorderColor = .borderSeparator
@ -307,9 +310,7 @@ extension OpenGroupSuggestionGrid {
snContentView.pin(to: self)
}
private func update() {
guard let room: OpenGroupAPI.Room = room else { return }
fileprivate func update(with room: OpenGroupAPI.Room, existingImageData: Data?) {
label.text = room.name
// Only continue if we have a room image
@ -322,11 +323,13 @@ extension OpenGroupSuggestionGrid {
Publishers
.MergeMany(
Storage.shared
.readPublisherFlatMap { db in
OpenGroupManager
.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer)
}
OpenGroupManager
.roomImage(
fileId: imageId,
for: room.token,
on: OpenGroupAPI.defaultServer,
existingData: existingImageData
)
.map { ($0, true) }
.eraseToAnyPublisher(),
// If we have already received the room image then the above will emit first and

View File

@ -220,7 +220,7 @@ final class NukeDataModal: Modal {
}
}
private func deleteAllLocalData() {
private func deleteAllLocalData(using dependencies: Dependencies = Dependencies()) {
// Unregister push notifications if needed
let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs]
let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken]
@ -244,7 +244,10 @@ final class NukeDataModal: Modal {
UserDefaults.removeAll()
// 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
SnodeAPI.clearSnodePool()

View File

@ -146,19 +146,13 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
}
public func sendOffer(
_ db: Database,
to sessionId: String,
to thread: SessionThread,
isRestartingICEConnection: Bool = false
) -> AnyPublisher<Void, Error> {
SNLog("[Calls] Sending offer message.")
let uuid: String = self.uuid
let mediaConstraints: RTCMediaConstraints = mediaConstraints(isRestartingICEConnection)
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else {
return Fail(error: WebRTCSessionError.noThread)
.eraseToAnyPublisher()
}
return Deferred {
Future<Void, Error> { [weak self] resolver in
self?.peerConnection?.offer(for: mediaConstraints) { sdp, error in

View File

@ -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
///
/// **Note:** This method intentionally does **not** save the newly created Profile,

View File

@ -7,30 +7,15 @@ import Sodium
import SessionUtilitiesKit
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
public final class OpenGroupManager {
public typealias DefaultRoomInfo = (room: OpenGroupAPI.Room, existingImageData: Data?)
// MARK: - Cache
public class Cache: OGMCacheType {
public var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>?
public class Cache: OGMMutableCacheType {
public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>?
public var groupImagePublishers: [String: AnyPublisher<Data, Error>] = [:]
public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server
@ -60,10 +45,7 @@ public final class OpenGroupManager {
// MARK: - Variables
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())
public static let shared: OpenGroupManager = OpenGroupManager()
// MARK: - Polling
@ -87,6 +69,7 @@ public final class OpenGroupManager {
}
.defaulting(to: [])
// Update the cache state and re-create all of the pollers
dependencies.mutableCache.mutate { cache in
cache.isPolling = true
cache.pollers = servers
@ -94,14 +77,9 @@ public final class OpenGroupManager {
result[server.lowercased()]?.stop() // Should never occur
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 +163,7 @@ public final class OpenGroupManager {
}
// 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
}
@ -209,13 +187,11 @@ public final class OpenGroupManager {
publicKey: String,
calledFromConfigHandling: Bool,
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 hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) {
SNLog("Ignoring join open group attempt (already joined), user initiated: \(!calledFromConfigHandling)")
return Just(())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
return false
}
// Store the open group information
@ -270,72 +246,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
/// 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
db.afterNextTransactionNested { _ in
OpenGroupAPI.workQueue.async {
resolver(Result.success(()))
}
return true
}
public func performInitialRequestsAfterAdd(
successfullyAddedGroup: Bool,
roomToken: String,
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
dependencies.storage
.readPublisher { db in
try OpenGroupAPI
.preparedCapabilitiesAndRoom(
db,
for: roomToken,
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(
return OpenGroupAPI.defaultServer
}()
return dependencies.storage
.readPublisher { db in
try OpenGroupAPI
.preparedCapabilitiesAndRoom(
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(()))
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,
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(
receiveCompletion: { result in
switch result {
case .finished: break
case .failure: SNLog("Failed to join open group.")
.handleEvents(
receiveCompletion: { result in
switch result {
case .finished: break
case .failure: SNLog("Failed to join open group.")
}
}
}
)
.eraseToAnyPublisher()
)
.eraseToAnyPublisher()
}
public func delete(
@ -534,9 +524,11 @@ public final class OpenGroupManager {
// Start the poller if needed
if dependencies.cache.pollers[server.lowercased()] == nil {
dependencies.mutableCache.mutate {
$0.pollers[server.lowercased()]?.stop()
$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)
@ -549,10 +541,10 @@ public final class OpenGroupManager {
{
OpenGroupManager
.roomImage(
db,
fileId: imageId,
for: roomToken,
on: server,
existingData: openGroup.imageData,
using: dependencies
)
// Note: We need to subscribe and receive on different threads to ensure the
@ -593,45 +585,26 @@ public final class OpenGroupManager {
on server: String,
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 {
SNLog("Couldn't handle open group messages.")
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
.filter { $0.deleted != true }
.sorted { lhs, rhs in lhs.id < rhs.id }
var messageServerInfoToRemove: [(id: Int64, seqNo: Int64)] = messages
.filter { $0.deleted == true }
.map { ($0.id, $0.seqNo) }
let updateSeqNo: (Database, String, inout Int64, Int64, OGMDependencies) -> () = { db, openGroupId, lastValidSeqNo, seqNo, dependencies in
// 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
}
var largestValidSeqNo: Int64 = openGroup.sequenceNumber
// Process the messages
var lastValidSeqNo: Int64 = -1
sortedMessages.forEach { message in
if message.base64EncodedData == nil && message.reactions == nil {
messageServerInfoToRemove.append((message.id, message.seqNo))
return updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies)
return
}
// Handle messages
@ -658,7 +631,7 @@ public final class OpenGroupManager {
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
dependencies: dependencies
)
updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies)
largestValidSeqNo = max(largestValidSeqNo, message.seqNo)
}
}
catch {
@ -703,7 +676,7 @@ public final class OpenGroupManager {
openGroupMessageServerId: message.id,
openGroupReactions: reactions
)
updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies)
largestValidSeqNo = max(largestValidSeqNo, message.seqNo)
}
catch {
SNLog("Couldn't handle open group reactions due to error: \(error).")
@ -712,17 +685,27 @@ public final class OpenGroupManager {
}
// 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 }
_ = try? Interaction
.filter(Interaction.Columns.threadId == openGroup.threadId)
.filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId))
.deleteAll(db)
// Update the seqNo for deletions
if let lastDeletionSeqNo: Int64 = messageServerInfoToRemove.map({ $0.seqNo }).max() {
updateSeqNo(db, openGroup.id, &lastValidSeqNo, lastDeletionSeqNo, dependencies)
// Now that we've finished processing all valid message changes we can update the `sequenceNumber` to
// the `largestValidSeqNo` value
_ = try? OpenGroup
.filter(id: openGroup.id)
.updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo))
// Update pendingChange cache based on the `largestValidSeqNo` value
dependencies.mutableCache.mutate {
$0.pendingChanges = $0.pendingChanges
.filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo }
}
}
@ -1013,14 +996,14 @@ public final class OpenGroupManager {
subscribeQueue: 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
if let existingPublisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.cache.defaultRoomsPublisher {
if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.cache.defaultRoomsPublisher {
return existingPublisher
}
// 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
try OpenGroupAPI.preparedCapabilitiesAndRooms(
db,
@ -1032,8 +1015,8 @@ public final class OpenGroupManager {
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
.retry(8)
.map { info, response in
dependencies.storage.writeAsync { db in
.map { info, response -> [DefaultRoomInfo]? in
dependencies.storage.write { db -> [DefaultRoomInfo] in
// Store the capabilities first
OpenGroupManager.handleCapabilities(
db,
@ -1042,8 +1025,8 @@ public final class OpenGroupManager {
)
// Then the rooms
response.rooms.data
.compactMap { room -> (String, String)? in
return response.rooms.data
.map { room -> DefaultRoomInfo in
// 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)
do {
@ -1066,24 +1049,32 @@ public final class OpenGroupManager {
}
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)
}
.forEach { imageId, roomToken in
roomImage(
db,
fileId: imageId,
for: roomToken,
on: OpenGroupAPI.defaultServer,
using: dependencies
)
return (room, existingImageData)
}
}
return response.rooms.data
}
.map { ($0 ?? []) }
.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
switch result {
case .finished: break
@ -1108,10 +1099,10 @@ public final class OpenGroupManager {
}
@discardableResult public static func roomImage(
_ db: Database,
fileId: String,
for roomToken: String,
on server: String,
existingData: Data?,
using dependencies: OGMDependencies = OGMDependencies(
subscribeQueue: .global(qos: .background)
)
@ -1130,16 +1121,12 @@ public final class OpenGroupManager {
let now: Date = dependencies.date
let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude)
let updateInterval: TimeInterval = (7 * 24 * 60 * 60)
let canUseExistingImage: Bool = (
server.lowercased() == OpenGroupAPI.defaultServer &&
timeSinceLastUpdate < updateInterval
)
if
server.lowercased() == OpenGroupAPI.defaultServer,
timeSinceLastUpdate < updateInterval,
let data = try? OpenGroup
.select(.imageData)
.filter(id: threadId)
.asRequest(of: Data.self)
.fetchOne(db)
{
if canUseExistingImage, let data: Data = existingData {
return Just(data)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
@ -1149,33 +1136,54 @@ public final class OpenGroupManager {
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
let publisher: AnyPublisher<Data, Error> = Deferred {
Future { resolver in
dependencies.subscribeQueue.async {
// Hold on to the publisher until it has completed at least once
OpenGroupAPI
.send(
data: sendData,
using: dependencies
)
dependencies.storage
.readPublisher { db -> (Data?, OpenGroupAPI.PreparedSendData<Data>?) in
if canUseExistingImage {
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(
receiveCompletion: { result in
switch result {
@ -1183,7 +1191,7 @@ public final class OpenGroupManager {
case .failure(let error): resolver(Result.failure(error))
}
},
receiveValue: { _, imageData in
receiveValue: { imageData in
if server.lowercased() == OpenGroupAPI.defaultServer {
dependencies.storage.write { db in
_ = try OpenGroup
@ -1216,25 +1224,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
extension OpenGroupManager {
public class OGMDependencies: SMKDependencies {
internal var _mutableCache: Atomic<Atomic<OGMCacheType>?>
public var mutableCache: Atomic<OGMCacheType> {
get { Dependencies.getValueSettingIfNull(&_mutableCache) { OpenGroupManager.shared.mutableCache } }
set { _mutableCache.mutate { $0 = newValue } }
}
/// These should not be accessed directly but rather via an instance of this type
private static let _cacheInstance: OGMMutableCacheType = OpenGroupManager.Cache()
private static let _cacheInstanceAccessQueue = DispatchQueue(label: "OGMCacheInstanceAccess")
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(
subscribeQueue: DispatchQueue? = nil,
receiveQueue: DispatchQueue? = nil,
cache: Atomic<OGMCacheType>? = nil,
cache: OGMMutableCacheType? = nil,
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
generalCache: MutableGeneralCacheType? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
sodium: SodiumType? = nil,

View File

@ -8,6 +8,7 @@ public enum OpenGroupAPIError: LocalizedError {
case noPublicKey
case invalidEmoji
case invalidPreparedData
case invalidPoll
public var errorDescription: String? {
switch self {
@ -16,6 +17,7 @@ public enum OpenGroupAPIError: LocalizedError {
case .noPublicKey: return "Couldn't find server public key."
case .invalidEmoji: return "The emoji is invalid."
case .invalidPreparedData: return "Invalid PreparedSendData provided."
case .invalidPoll: return "Poller in invalid state."
}
}
}

View File

@ -61,7 +61,7 @@ public class SMKDependencies: SSKDependencies {
subscribeQueue: DispatchQueue? = nil,
receiveQueue: DispatchQueue? = nil,
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
generalCache: MutableGeneralCacheType? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
sodium: SodiumType? = nil,

View File

@ -200,7 +200,7 @@ extension MessageReceiver {
// Open groups
for openGroupURL in message.openGroups {
if let (room, server, publicKey) = SessionUtil.parseCommunity(url: openGroupURL) {
OpenGroupManager.shared
let successfullyAddedGroup: Bool = OpenGroupManager.shared
.add(
db,
roomToken: room,
@ -208,7 +208,20 @@ extension MessageReceiver {
publicKey: publicKey,
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()
}
}
}
}
}

View File

@ -12,130 +12,130 @@ extension MessageSender {
public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:])
public static func createClosedGroup(
_ db: Database,
name: String,
members: Set<String>
) throws -> AnyPublisher<SessionThread, Error> {
let userPublicKey: String = getUserHexEncodedPublicKey(db)
var members: Set<String> = members
// Generate the group's public key
let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair()
let groupPublicKey: String = KeyPair(
publicKey: groupKeyPair.publicKey.bytes,
secretKey: groupKeyPair.privateKey.bytes
).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix
// Generate the key pair that'll be used for encryption and decryption
let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair()
// Create the group
members.insert(userPublicKey) // Ensure the current user is included in the member list
let membersAsData: [Data] = members.map { Data(hex: $0) }
let admins: Set<String> = [ userPublicKey ]
let adminsAsData: [Data] = admins.map { Data(hex: $0) }
let formationTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
// Create the relevant objects in the database
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true)
try ClosedGroup(
threadId: groupPublicKey,
name: name,
formationTimestamp: formationTimestamp
).insert(db)
// Store the key pair
let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
try ClosedGroupKeyPair(
threadId: groupPublicKey,
publicKey: encryptionKeyPair.publicKey,
secretKey: encryptionKeyPair.privateKey,
receivedTimestamp: latestKeyPairReceivedTimestamp
).insert(db)
// Create the member objects
try admins.forEach { adminId in
try GroupMember(
groupId: groupPublicKey,
profileId: adminId,
role: .admin,
isHidden: false
).save(db)
}
try members.forEach { memberId in
try GroupMember(
groupId: groupPublicKey,
profileId: memberId,
role: .standard,
isHidden: false
).save(db)
}
// Update libSession
try SessionUtil.add(
db,
groupPublicKey: groupPublicKey,
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(
) -> AnyPublisher<SessionThread, Error> {
Storage.shared
.writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData]) in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
var members: Set<String> = members
// Generate the group's public key
let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair()
let groupPublicKey: String = KeyPair(
publicKey: groupKeyPair.publicKey.bytes,
secretKey: groupKeyPair.privateKey.bytes
).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix
// Generate the key pair that'll be used for encryption and decryption
let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair()
// Create the group
members.insert(userPublicKey) // Ensure the current user is included in the member list
let membersAsData: [Data] = members.map { Data(hex: $0) }
let admins: Set<String> = [ userPublicKey ]
let adminsAsData: [Data] = admins.map { Data(hex: $0) }
let formationTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
// Create the relevant objects in the database
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true)
try ClosedGroup(
threadId: groupPublicKey,
name: name,
formationTimestamp: formationTimestamp
).insert(db)
// Store the key pair
let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
try ClosedGroupKeyPair(
threadId: groupPublicKey,
publicKey: encryptionKeyPair.publicKey,
secretKey: encryptionKeyPair.privateKey,
receivedTimestamp: latestKeyPairReceivedTimestamp
).insert(db)
// Create the member objects
try admins.forEach { adminId in
try GroupMember(
groupId: groupPublicKey,
profileId: adminId,
role: .admin,
isHidden: false
).save(db)
}
try members.forEach { memberId in
try GroupMember(
groupId: groupPublicKey,
profileId: memberId,
role: .standard,
isHidden: false
).save(db)
}
// Update libSession
try SessionUtil.add(
db,
message: ClosedGroupControlMessage(
kind: .new(
publicKey: Data(hex: groupPublicKey),
name: name,
encryptionKeyPair: KeyPair(
publicKey: encryptionKeyPair.publicKey.bytes,
secretKey: encryptionKeyPair.privateKey.bytes
),
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
groupPublicKey: groupPublicKey,
name: name,
latestKeyPairPublicKey: encryptionKeyPair.publicKey,
latestKeyPairSecretKey: encryptionKeyPair.privateKey,
latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp,
disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey),
members: members,
admins: admins
)
}
return 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: groupPublicKey,
publicKey: userPublicKey
let memberSendData: [MessageSender.PreparedSendData] = try members
.map { memberId -> MessageSender.PreparedSendData in
try MessageSender.preparedSendData(
db,
message: ClosedGroupControlMessage(
kind: .new(
publicKey: Data(hex: groupPublicKey),
name: name,
encryptionKeyPair: KeyPair(
publicKey: encryptionKeyPair.publicKey.bytes,
secretKey: encryptionKeyPair.privateKey.bytes
),
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()
@ -148,7 +148,6 @@ extension MessageSender {
///
/// The returned promise is fulfilled when the message has been sent to the group.
private static func generateAndSendNewEncryptionKeyPair(
_ db: Database,
targetMembers: Set<String>,
userPublicKey: String,
allGroupMembers: [GroupMember],
@ -159,65 +158,62 @@ extension MessageSender {
.eraseToAnyPublisher()
}
let newKeyPair: ClosedGroupKeyPair
let sendData: MessageSender.PreparedSendData
do {
// Generate the new encryption key pair
let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair()
newKeyPair = ClosedGroupKeyPair(
threadId: closedGroup.threadId,
publicKey: legacyNewKeyPair.publicKey,
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
return Storage.shared
.readPublisher { db -> (ClosedGroupKeyPair, MessageSender.PreparedSendData) in
// Generate the new encryption key pair
let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair()
let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair(
threadId: closedGroup.threadId,
publicKey: legacyNewKeyPair.publicKey,
secretKey: legacyNewKeyPair.privateKey,
receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
)
}
catch {
return Fail(error: error)
.eraseToAnyPublisher()
}
return MessageSender.sendImmediate(preparedSendData: sendData)
.map { _ in newKeyPair }
.eraseToAnyPublisher()
// 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)
}
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(
receiveOutput: { newKeyPair in
/// Store it **after** having sent out the message to the group
@ -253,116 +249,110 @@ extension MessageSender {
}
public static func update(
_ db: Database,
groupPublicKey: String,
with members: Set<String>,
name: String
) -> AnyPublisher<Void, Error> {
// Get the group, check preconditions & prepare
guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else {
SNLog("Can't update nonexistent closed group.")
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))
return Storage.shared
.writePublisher { db -> (String, ClosedGroup, [GroupMember], Set<String>) in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
// 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)
// Get the group, check preconditions & prepare
guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else {
SNLog("Can't update nonexistent closed group.")
throw MessageSenderError.noThread
}
guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: groupPublicKey) else {
throw MessageSenderError.invalidClosedGroupUpdate
}
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
try MessageSender.send(
db,
message: ClosedGroupControlMessage(kind: .nameChange(name: name)),
interactionId: interactionId,
threadId: groupPublicKey,
threadVariant: .legacyGroup
)
// Retrieve member info
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
throw MessageSenderError.invalidClosedGroupUpdate
}
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: closedGroup.threadId,
name: name
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 {
throw MessageSenderError.invalidClosedGroupUpdate
}
}
// Remove members if needed
return (
userPublicKey,
closedGroup,
allGroupMembers,
Set(standardAndZombieMemberIds).subtracting(members)
)
}
}
catch {
return Fail(error: error)
.eraseToAnyPublisher()
}
// Retrieve member info
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
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,
.flatMap { userPublicKey, closedGroup, allGroupMembers, removedMembers -> AnyPublisher<Void, Error> in
guard !removedMembers.isEmpty else {
return Just(())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
return removeMembers(
removedMembers: removedMembers,
userPublicKey: userPublicKey,
allGroupMembers: allGroupMembers,
closedGroup: closedGroup
)
.catch { _ in Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() }
.eraseToAnyPublisher()
}
catch {
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
.eraseToAnyPublisher()
}
}
return Just(())
.setFailureType(to: Error.self)
.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
/// generated and distributed.
private static func removeMembers(
_ db: Database,
removedMembers: Set<String>,
userPublicKey: String,
allGroupMembers: [GroupMember],
closedGroup: ClosedGroup
) throws -> AnyPublisher<Void, Error> {
) -> AnyPublisher<Void, Error> {
guard !removedMembers.contains(userPublicKey) else {
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 {
SNLog("Only an admin can remove members from a group.")
throw MessageSenderError.invalidClosedGroupUpdate
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
.eraseToAnyPublisher()
}
let groupMemberIds: [String] = allGroupMembers
@ -499,39 +490,39 @@ extension MessageSender {
.map { $0.profileId }
let members: Set<String> = Set(groupMemberIds).subtracting(removedMembers)
// Update zombie & member list
try GroupMember
.filter(GroupMember.Columns.groupId == closedGroup.threadId)
.filter(removedMembers.contains(GroupMember.Columns.profileId))
.filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role))
.deleteAll(db)
let interactionId: Int64?
// Notify the user if needed (not if only zombie members were removed)
if !removedMembers.subtracting(groupZombieIds).isEmpty {
let interaction: Interaction = try Interaction(
threadId: closedGroup.threadId,
authorId: userPublicKey,
variant: .infoClosedGroupUpdated,
body: ClosedGroupControlMessage.Kind
.membersRemoved(members: removedMembers.map { Data(hex: $0) })
.infoMessage(db, sender: userPublicKey),
timestampMs: SnodeAPI.currentOffsetTimestampMs()
).inserted(db)
guard let newInteractionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
interactionId = newInteractionId
}
else {
interactionId = nil
}
// Send the update to the group and generate + distribute a new encryption key pair
return MessageSender
.sendImmediate(
preparedSendData: try MessageSender
return Storage.shared
.writePublisher { db in
// Update zombie & member list
try GroupMember
.filter(GroupMember.Columns.groupId == closedGroup.threadId)
.filter(removedMembers.contains(GroupMember.Columns.profileId))
.filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role))
.deleteAll(db)
let interactionId: Int64?
// Notify the user if needed (not if only zombie members were removed)
if !removedMembers.subtracting(groupZombieIds).isEmpty {
let interaction: Interaction = try Interaction(
threadId: closedGroup.threadId,
authorId: userPublicKey,
variant: .infoClosedGroupUpdated,
body: ClosedGroupControlMessage.Kind
.membersRemoved(members: removedMembers.map { Data(hex: $0) })
.infoMessage(db, sender: userPublicKey),
timestampMs: SnodeAPI.currentOffsetTimestampMs()
).inserted(db)
guard let newInteractionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
interactionId = newInteractionId
}
else {
interactionId = nil
}
// Send the update to the group and generate + distribute a new encryption key pair
return try MessageSender
.preparedSendData(
db,
message: ClosedGroupControlMessage(
@ -546,18 +537,15 @@ extension MessageSender {
.defaultNamespace,
interactionId: interactionId
)
)
}
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.flatMap { _ -> AnyPublisher<Void, Error> in
Storage.shared
.writePublisherFlatMap { db in
generateAndSendNewEncryptionKeyPair(
db,
targetMembers: members,
userPublicKey: userPublicKey,
allGroupMembers: allGroupMembers,
closedGroup: closedGroup
)
}
MessageSender.generateAndSendNewEncryptionKeyPair(
targetMembers: members,
userPublicKey: userPublicKey,
allGroupMembers: allGroupMembers,
closedGroup: closedGroup
)
}
.eraseToAnyPublisher()
}

View File

@ -57,38 +57,47 @@ extension OpenGroupAPI {
) {
guard hasStarted else { return }
let minPollFailureCount: TimeInterval = Storage.shared
.read { db in
dependencies.storage
.readPublisher { [server = server] db in
try OpenGroup
.filter(OpenGroup.Columns.server == server)
.select(min(OpenGroup.Columns.pollFailureCount))
.asRequest(of: TimeInterval.self)
.fetchOne(db)
}
.defaulting(to: 0)
let lastPollStart: TimeInterval = Date().timeIntervalSince1970
let nextPollInterval: TimeInterval = getInterval(for: minPollFailureCount, minInterval: Poller.minPollInterval, maxInterval: Poller.maxPollInterval)
// Wait until the last poll completes before polling again ensuring we don't poll any faster than
// the 'nextPollInterval' value
poll(using: dependencies)
.tryFlatMap { [weak self] minPollFailureCount -> AnyPublisher<(TimeInterval, TimeInterval), Error> in
guard let strongSelf = self else { throw OpenGroupAPIError.invalidPoll }
let lastPollStart: TimeInterval = Date().timeIntervalSince1970
let nextPollInterval: TimeInterval = Poller.getInterval(
for: (minPollFailureCount ?? 0),
minInterval: Poller.minPollInterval,
maxInterval: Poller.maxPollInterval
)
// 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, immediatelyIfMain: true)
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in
receiveValue: { [weak self] lastPollStart, nextPollInterval in
let currentTime: TimeInterval = Date().timeIntervalSince1970
let remainingInterval: TimeInterval = max(0, nextPollInterval - (currentTime - lastPollStart))
guard remainingInterval > 0 else {
return Threading.pollerQueue.async {
return dependencies.subscribeQueue.async {
self?.pollRecursively(using: dependencies)
}
}
self?.timer = Timer.scheduledTimerOnMainThread(withTimeInterval: remainingInterval, repeats: false) { timer in
timer.invalidate()
Threading.pollerQueue.async {
dependencies.subscribeQueue.async {
self?.pollRecursively(using: dependencies)
}
}
@ -120,6 +129,11 @@ extension OpenGroupAPI {
self.isPolling = true
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
.readPublisher { db -> (Int64, PreparedSendData<BatchResponse>) in
@ -136,11 +150,8 @@ extension OpenGroupAPI {
.preparedPoll(
db,
server: server,
hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true,
timeSinceLastPoll: (
dependencies.cache.timeSinceLastPoll[server] ??
dependencies.cache.getTimeSinceLastOpen(using: dependencies)
),
hasPerformedInitialPoll: hasPerformedInitialPoll,
timeSinceLastPoll: timeSinceLastPoll,
using: dependencies
)
)
@ -591,12 +602,12 @@ extension OpenGroupAPI {
}
}
}
}
// MARK: - Convenience
// MARK: - Convenience
fileprivate static func getInterval(for failureCount: TimeInterval, minInterval: TimeInterval, maxInterval: TimeInterval) -> TimeInterval {
// Arbitrary backoff factor...
return min(maxInterval, minInterval + pow(2, failureCount))
fileprivate static func getInterval(for failureCount: TimeInterval, minInterval: TimeInterval, maxInterval: TimeInterval) -> TimeInterval {
// Arbitrary backoff factor...
return min(maxInterval, minInterval + pow(2, failureCount))
}
}
}

View File

@ -138,7 +138,7 @@ internal extension SessionUtil {
// Add any new communities (via the OpenGroupManager)
communities.forEach { community in
OpenGroupManager.shared
let successfullyAddedGroup: Bool = OpenGroupManager.shared
.add(
db,
roomToken: community.data.roomToken,
@ -146,7 +146,20 @@ internal extension SessionUtil {
publicKey: community.data.publicKey,
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
// this stage)

View File

@ -403,40 +403,35 @@ public enum SessionUtil {
guard SessionUtil.userConfigsEnabled else { return [] }
return Storage.shared
.read { db -> [String] in
.read { db -> Set<ConfigDump.Variant> in
guard Identity.userExists(db) else { return [] }
let existingDumpVariants: Set<ConfigDump.Variant> = (try? ConfigDump
return try ConfigDump
.select(.variant)
.filter(ConfigDump.Columns.publicKey == publicKey)
.asRequest(of: ConfigDump.Variant.self)
.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([], +)
.fetchSet(db)
}
.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

View File

@ -498,7 +498,7 @@ public struct ProfileManager {
dependencies: Dependencies = Dependencies()
) throws {
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] = []
// Name

View File

@ -118,9 +118,9 @@ class OpenGroupManagerSpec: QuickSpec {
dependencies = OpenGroupManager.OGMDependencies(
subscribeQueue: DispatchQueue.main,
receiveQueue: DispatchQueue.main,
cache: Atomic(mockOGMCache),
cache: mockOGMCache,
onionApi: TestCapabilitiesAndRoomApi.self,
generalCache: Atomic(mockGeneralCache),
generalCache: mockGeneralCache,
storage: mockStorage,
sodium: mockSodium,
genericHash: mockGenericHash,

View File

@ -6,9 +6,9 @@ import SessionUtilitiesKit
@testable import SessionMessagingKit
class MockOGMCache: Mock<OGMCacheType>, OGMCacheType {
var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>? {
get { return accept() as? AnyPublisher<[OpenGroupAPI.Room], Error> }
class MockOGMCache: Mock<OGMMutableCacheType>, OGMMutableCacheType {
var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? {
get { return accept() as? AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> }
set { accept(args: [newValue]) }
}

View File

@ -17,7 +17,7 @@ open class SSKDependencies: Dependencies {
subscribeQueue: DispatchQueue? = nil,
receiveQueue: DispatchQueue? = nil,
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
generalCache: MutableGeneralCacheType? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
standardUserDefaults: UserDefaultsType? = nil,

View File

@ -49,10 +49,23 @@ public final class ReplaySubject<Output, Failure: Error>: Subject {
public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
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)) { [buffer = buffer, completion = completion] subscription in
subscription.replay(buffer, completion: completion)
}
subscriber.receive(subscription: subscription)
subscriptions.append(subscription)
subscription.replay(buffer, completion: completion)
}
}
@ -62,17 +75,21 @@ public final class ReplaySubjectSubscription<Output, Failure: Error>: Subscripti
private let downstream: AnySubscriber<Output, Failure>
private var isCompleted: Bool = false
private var demand: Subscribers.Demand = .none
private var onInitialDemand: ((ReplaySubjectSubscription) -> ())?
// MARK: - Initialization
init(downstream: AnySubscriber<Output, Failure>) {
init(downstream: AnySubscriber<Output, Failure>, onInitialDemand: @escaping (ReplaySubjectSubscription) -> ()) {
self.downstream = downstream
self.onInitialDemand = onInitialDemand
}
// MARK: - Subscription
public func request(_ newDemand: Subscribers.Demand) {
demand += newDemand
onInitialDemand?(self)
onInitialDemand = nil
}
public func cancel() {

View File

@ -516,24 +516,6 @@ open class Storage {
// 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 {
func publisher(
in storage: Storage,

View File

@ -4,6 +4,10 @@ import Foundation
import GRDB
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: DispatchQueue {
get { Dependencies.getValueSettingIfNull(&_subscribeQueue) { DispatchQueue.global(qos: .default) } }
@ -16,10 +20,25 @@ open class Dependencies {
set { _receiveQueue.mutate { $0 = newValue } }
}
public var _generalCache: Atomic<Atomic<GeneralCacheType>?>
public var generalCache: Atomic<GeneralCacheType> {
get { Dependencies.getValueSettingIfNull(&_generalCache) { General.cache } }
set { _generalCache.mutate { $0 = newValue } }
public var _mutableGeneralCache: Atomic<MutableGeneralCacheType?>
public var mutableGeneralCache: Atomic<MutableGeneralCacheType> {
get {
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?>
@ -51,7 +70,7 @@ open class Dependencies {
public init(
subscribeQueue: DispatchQueue? = nil,
receiveQueue: DispatchQueue? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
generalCache: MutableGeneralCacheType? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
standardUserDefaults: UserDefaultsType? = nil,
@ -59,7 +78,7 @@ open class Dependencies {
) {
_subscribeQueue = Atomic(subscribeQueue)
_receiveQueue = Atomic(receiveQueue)
_generalCache = Atomic(generalCache)
_mutableGeneralCache = Atomic(generalCache)
_storage = Atomic(storage)
_scheduler = Atomic(scheduler)
_standardUserDefaults = Atomic(standardUserDefaults)
@ -77,4 +96,14 @@ open class Dependencies {
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)
}
}

View File

@ -3,35 +3,48 @@
import Foundation
import GRDB
public protocol GeneralCacheType {
var encodedPublicKey: String? { get set }
var recentReactionTimestamps: [Int64] { get set }
}
// MARK: - General.Cache
public enum General {
public class Cache: GeneralCacheType {
public class Cache: MutableGeneralCacheType {
public var encodedPublicKey: String? = nil
public var recentReactionTimestamps: [Int64] = []
}
public static var cache: Atomic<GeneralCacheType> = Atomic(Cache())
}
// MARK: - GeneralError
public enum GeneralError: Error {
case invalidSeed
case keyGenerationFailed
case randomGenerationFailed
}
// MARK: - Convenience
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
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 ""
}
// 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 }
}