Merge branch 'database-refactor' into add-documents-section

pull/646/head
ryanzhao 2 years ago
commit 1e1c5a5fde

@ -263,7 +263,6 @@
B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */; };
B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */; };
B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */; };
B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */; };
B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */; };
B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; };
B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; };
@ -1300,7 +1299,6 @@
B8EB20E6263F7E4B00773E52 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = "<group>"; };
B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = "<group>"; };
B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Contacts.swift"; sourceTree = "<group>"; };
B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Subscripting.swift"; sourceTree = "<group>"; };
B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = "<group>"; };
B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = "<group>"; };
@ -2634,7 +2632,6 @@
FD17D79A27F40ADA00122BE0 /* LegacyDatabase */,
FD17D79427F3E03300122BE0 /* Migrations */,
FD09796C27FA6C8B00936362 /* Models */,
B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */,
);
path = Database;
sourceTree = "<group>";
@ -5182,7 +5179,6 @@
FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */,
C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */,
FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */,
B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */,
FD09796E27FA6D0000936362 /* Contact.swift in Sources */,
C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */,
FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */,
@ -6806,7 +6802,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 354;
CURRENT_PROJECT_VERSION = 356;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -6878,7 +6874,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 354;
CURRENT_PROJECT_VERSION = 356;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",

@ -152,6 +152,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId)
.map { UIImage(data: $0) }
.defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300))
WebRTCSession.current = self.webRTCSession

@ -174,6 +174,7 @@ final class ContextMenuVC: UIViewController {
animations: { [weak self] in
self?.blurView.effect = nil
self?.menuView.alpha = 0
self?.snapshot.alpha = 0
self?.timestampLabel.alpha = 0
},
completion: { [weak self] _ in

@ -337,6 +337,15 @@ extension ConversationVC:
modal.proceed = { self.sendMessage(hasPermissionToSendSeed: true) }
return present(modal, animated: true, completion: nil)
}
// Clearing this out immediately (even though it already happens in 'messageSent') to prevent
// "double sending" if the user rapidly taps the send button
DispatchQueue.main.async { [weak self] in
self?.snInputView.text = ""
self?.snInputView.quoteDraftInfo = nil
self?.resetMentions()
}
// Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
// use it to determine if the user is creating a new thread and update the 'isApproved'

@ -185,6 +185,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
}()
),
PagedData.ObservedChanges(
table: Profile.self,
columns: [.profilePictureFileName],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
}()
)
],
filterSQL: MessageViewModel.filterSQL(threadId: threadId),

@ -38,6 +38,15 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
// MARK: - UI Components
private lazy var viewsToMoveForReply: [UIView] = [
bubbleView,
bubbleBackgroundView,
profilePictureView,
replyButton,
timerView,
messageStatusImageView
]
private lazy var profilePictureView: ProfilePictureView = {
let result: ProfilePictureView = ProfilePictureView()
result.set(.height, to: Values.verySmallProfilePictureSize)
@ -619,8 +628,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
super.prepareForReuse()
unloadContent?()
let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ]
viewsToMove.forEach { $0.transform = .identity }
viewsToMoveForReply.forEach { $0.transform = .identity }
replyButton.alpha = 0
timerView.prepareForReuse()
}
@ -726,9 +734,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
let viewsToMove: [UIView] = [
bubbleView, bubbleBackgroundView, profilePictureView, replyButton, timerView, messageStatusImageView
]
let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0)
switch gestureRecognizer.state {
@ -739,7 +744,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
let damping: CGFloat = 20
let sign: CGFloat = -1
let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign
viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) }
viewsToMoveForReply.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) }
if timerView.isHidden {
replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX
} else {
@ -778,10 +783,9 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
}
private func resetReply() {
let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ]
UIView.animate(withDuration: 0.25) {
viewsToMove.forEach { $0.transform = .identity }
self.replyButton.alpha = 0
UIView.animate(withDuration: 0.25) { [weak self] in
self?.viewsToMoveForReply.forEach { $0.transform = .identity }
self?.replyButton.alpha = 0
}
}

@ -53,8 +53,6 @@ CGFloat kIconViewLength = 24;
return self;
}
[self commonInit];
return self;
}
@ -65,8 +63,6 @@ CGFloat kIconViewLength = 24;
return self;
}
[self commonInit];
return self;
}
@ -77,32 +73,11 @@ CGFloat kIconViewLength = 24;
return self;
}
[self commonInit];
return self;
}
- (void)commonInit
{
[self observeNotifications];
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark
- (void)observeNotifications
{
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(otherUsersProfileDidChange:)
name:NSNotification.otherUsersProfileDidChange
object:nil];
}
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
self.threadId = threadId;
self.threadName = threadName;
@ -964,9 +939,10 @@ CGFloat kIconViewLength = 24;
#pragma mark - Notifications
// FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates
- (void)otherUsersProfileDidChange:(NSNotification *)notification
{
NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey];
NSString *recipientId = @"";//notification.userInfo[NSNotification.profileRecipientIdKey];
OWSAssertDebug(recipientId.length > 0);
if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) {

@ -518,6 +518,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
show(
threadViewModel.threadId,
variant: threadViewModel.threadVariant,
isMessageRequest: (threadViewModel.threadIsMessageRequest == true),
with: .none,
focusedInteractionId: nil,
animated: true
@ -651,6 +652,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
func show(
_ threadId: String,
variant: SessionThread.Variant,
isMessageRequest: Bool,
with action: ConversationViewModel.Action,
focusedInteractionId: Int64?,
animated: Bool
@ -659,8 +661,17 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
presentedVC.dismiss(animated: false, completion: nil)
}
let conversationVC: ConversationVC = ConversationVC(threadId: threadId, threadVariant: variant, focusedInteractionId: focusedInteractionId)
self.navigationController?.setViewControllers([ self, conversationVC ], animated: animated)
let finalViewControllers: [UIViewController] = [
self,
(isMessageRequest ? MessageRequestsViewController() : nil),
ConversationVC(
threadId: threadId,
threadVariant: variant,
focusedInteractionId: focusedInteractionId
)
].compactMap { $0 }
self.navigationController?.setViewControllers(finalViewControllers, animated: animated)
}
@objc private func openSettings() {

@ -267,7 +267,6 @@ public class MediaGalleryViewModel {
return SQL("""
\(attachment[.isVisualMedia]) = false AND
\(attachment[.isValid]) = true AND
\(attachment[.variant]) = \(Attachment.Variant.standard) AND
\(interaction[.threadId]) = \(threadId) AND
""")
}

@ -66,9 +66,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] successful, needsConfigSync in
guard successful else {
self?.showFailedMigrationAlert()
migrationsCompletion: { [weak self] error, needsConfigSync in
guard error == nil else {
self?.showFailedMigrationAlert(error: error)
return
}
@ -142,14 +142,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
self?.handleActivation()
}
/// Clear all notifications whenever we become active
///
/// **Note:** It looks like when opening the app from a notification, `userNotificationCenter(didReceive)` is
/// no longer always called before we become active so we need to dispatch this to run on the next run loop
DispatchQueue.main.async { [weak self] in
self?.clearAllNotificationsAndRestoreBadgeCount()
/// Clear all notifications whenever we become active once the app is ready
///
/// **Note:** It looks like when opening the app from a notification, `userNotificationCenter(didReceive)` is
/// no longer always called before `applicationDidBecomeActive` we need to trigger the "clear notifications" logic
/// within the `runNowOrWhenAppDidBecomeReady` callback and dispatch to the next run loop to ensure it runs after
/// the notification has actually been handled
DispatchQueue.main.async { [weak self] in
self?.clearAllNotificationsAndRestoreBadgeCount()
}
}
// On every activation, clear old temp directories.
@ -225,43 +227,58 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
}
private func showFailedMigrationAlert() {
private func showFailedMigrationAlert(error: Error?) {
let alert = UIAlertController(
title: "Session",
message: "DATABASE_MIGRATION_FAILED".localized(),
message: ((error as? StorageError) == StorageError.devRemigrationRequired ?
"The database has changed since the last version and you need to re-migrate (this will close the app and migrate on the next launch)" :
"DATABASE_MIGRATION_FAILED".localized()
),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "modal_share_logs_title".localized(), style: .default) { _ in
ShareLogsModal.shareLogs(from: alert) { [weak self] in
self?.showFailedMigrationAlert()
}
})
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
// Remove the legacy database and any message hashes that have been migrated to the new DB
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
Storage.shared.write { db in
try SnodeReceivedMessageInfo.deleteAll(db)
}
// The re-run the migration (should succeed since there is no data)
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] successful, needsConfigSync in
guard successful else {
self?.showFailedMigrationAlert()
return
switch (error as? StorageError) {
case .devRemigrationRequired:
alert.addAction(UIAlertAction(title: "Re-Migrate Database", style: .default) { _ in
Storage.deleteDatabaseFiles()
try? Storage.deleteDbKeys()
exit(1)
})
default:
alert.addAction(UIAlertAction(title: "modal_share_logs_title".localized(), style: .default) { _ in
ShareLogsModal.shareLogs(from: alert) { [weak self] in
self?.showFailedMigrationAlert(error: error)
}
})
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
// Remove the legacy database and any message hashes that have been migrated to the new DB
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
self?.completePostMigrationSetup(needsConfigSync: needsConfigSync)
}
)
})
Storage.shared.write { db in
try SnodeReceivedMessageInfo.deleteAll(db)
}
// The re-run the migration (should succeed since there is no data)
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] error, needsConfigSync in
guard error == nil else {
self?.showFailedMigrationAlert(error: error)
return
}
self?.completePostMigrationSetup(needsConfigSync: needsConfigSync)
}
)
})
}
alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in
DDLog.flushLog()
exit(0)

@ -10,15 +10,21 @@ public struct SessionApp {
// MARK: - View Convenience Methods
public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) {
let maybeThread: SessionThread? = Storage.shared.write { db in
try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact)
let maybeThreadInfo: (thread: SessionThread, isMessageRequest: Bool)? = Storage.shared.write { db in
let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact)
return (thread, thread.isMessageRequest(db))
}
guard let variant: SessionThread.Variant = maybeThread?.variant else { return }
guard
let variant: SessionThread.Variant = maybeThreadInfo?.thread.variant,
let isMessageRequest: Bool = maybeThreadInfo?.isMessageRequest
else { return }
self.presentConversation(
for: threadId,
threadVariant: variant,
isMessageRequest: isMessageRequest,
action: action,
focusInteractionId: nil,
animated: animated
@ -28,6 +34,7 @@ public struct SessionApp {
public static func presentConversation(
for threadId: String,
threadVariant: SessionThread.Variant,
isMessageRequest: Bool,
action: ConversationViewModel.Action,
focusInteractionId: Int64?,
animated: Bool
@ -37,6 +44,7 @@ public struct SessionApp {
self.presentConversation(
for: threadId,
threadVariant: threadVariant,
isMessageRequest: isMessageRequest,
action: action,
focusInteractionId: focusInteractionId,
animated: animated
@ -48,6 +56,7 @@ public struct SessionApp {
homeViewController.wrappedValue?.show(
threadId,
variant: threadVariant,
isMessageRequest: isMessageRequest,
with: action,
focusedInteractionId: focusInteractionId,
animated: animated

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -665,3 +665,4 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -659,4 +659,4 @@
"CHATS_TITLE" = "Chats";
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";

@ -142,31 +142,11 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
}
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
// If the thread is a message request and the user hasn't hidden message requests then we need
// to check if this is the only message request thread (group threads can't be message requests
// so just ignore those and if the user has hidden message requests then we want to show the
// notification regardless of how many message requests there are)
if thread.variant == .contact {
if isMessageRequest && !db[.hasHiddenMessageRequests] {
let numMessageRequestThreads: Int = (try? SessionThread
.messageRequestsQuery(userPublicKey: userPublicKey, includeNonVisible: true)
.fetchCount(db))
.defaulting(to: 0)
// Allow this to show a notification if there are no message requests (ie. this is the first one)
guard numMessageRequestThreads == 0 else { return }
}
else if isMessageRequest && db[.hasHiddenMessageRequests] {
// If there are other interactions on this thread already then don't show the notification
if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return }
db[.hasHiddenMessageRequests] = false
}
// Ensure we should be showing a notification for the thread
guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else {
return
}
let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll)
@ -180,11 +160,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
// see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody
// for more details.
let messageText: String? = String.filterNotificationText(rawMessageText)
// Don't fire the notification if the current user isn't mentioned
// and isOnlyNotifyingForMentions is on.
guard !thread.onlyNotifyForMentions || interaction.hasMention else { return }
let notificationTitle: String?
var notificationBody: String?

@ -170,7 +170,8 @@ final class DisplayNameVC: BaseVC {
ProfileManager.updateLocal(
queue: DispatchQueue.global(qos: .default),
profileName: displayName,
avatarImage: nil,
image: nil,
imageFilePath: nil,
requiredSync: false
)
let pnModeVC = PNModeVC()

@ -14,9 +14,6 @@ class ChatSettingsViewController: OWSTableViewController {
self.updateTableContents()
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: "CHATS_TITLE".localized(), hasCustomBackButton: false)
let closeButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "X"), style: .plain, target: self, action: #selector(close(_:)))
self.navigationItem.leftBarButtonItem = closeButton
}
override func viewDidAppear(_ animated: Bool) {
@ -47,9 +44,11 @@ class ChatSettingsViewController: OWSTableViewController {
// MARK: - Actions
@objc private func didToggleTrimOpenGroupsSwitch(_ sender: UISwitch) {
let switchIsOn: Bool = sender.isOn
Storage.shared.writeAsync(
updates: { db in
db[.trimOpenGroupMessagesOlderThanSixMonths] = !sender.isOn
db[.trimOpenGroupMessagesOlderThanSixMonths] = !switchIsOn
},
completion: { [weak self] _, _ in
self?.updateTableContents()

@ -7,7 +7,6 @@ import SessionMessagingKit
import SignalUtilitiesKit
final class SettingsVC: BaseVC, AvatarViewHelperDelegate {
private var profilePictureToBeUploaded: UIImage?
private var displayNameToBeUploaded: String?
private var isEditingDisplayName = false { didSet { handleIsEditingDisplayNameChanged() } }
@ -419,34 +418,47 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate {
}
}
func avatarDidChange(_ image: UIImage) {
let maxSize = Int(ProfileManager.maxAvatarDiameter)
profilePictureToBeUploaded = image.resizedImage(toFillPixelSize: CGSize(width: maxSize, height: maxSize))
updateProfile(isUpdatingDisplayName: false, isUpdatingProfilePicture: true)
func avatarDidChange(_ image: UIImage?, filePath: String?) {
updateProfile(
profilePicture: image,
profilePictureFilePath: filePath,
isUpdatingDisplayName: false,
isUpdatingProfilePicture: true
)
}
func clearAvatar() {
profilePictureToBeUploaded = nil
updateProfile(isUpdatingDisplayName: false, isUpdatingProfilePicture: true)
updateProfile(
profilePicture: nil,
profilePictureFilePath: nil,
isUpdatingDisplayName: false,
isUpdatingProfilePicture: true
)
}
private func updateProfile(isUpdatingDisplayName: Bool, isUpdatingProfilePicture: Bool) {
private func updateProfile(
profilePicture: UIImage?,
profilePictureFilePath: String?,
isUpdatingDisplayName: Bool,
isUpdatingProfilePicture: Bool
) {
let userDefaults = UserDefaults.standard
let name: String? = (displayNameToBeUploaded ?? Profile.fetchOrCreateCurrentUser().name)
let profilePicture: UIImage? = (profilePictureToBeUploaded ?? ProfileManager.profileAvatar(id: getUserHexEncodedPublicKey()))
let imageFilePath: String? = (profilePictureFilePath ?? ProfileManager.profileAvatarFilepath(id: getUserHexEncodedPublicKey()))
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded, profilePictureToBeUploaded] modalActivityIndicator in
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded] modalActivityIndicator in
ProfileManager.updateLocal(
queue: DispatchQueue.global(qos: .default),
profileName: (name ?? ""),
avatarImage: profilePicture,
image: profilePicture,
imageFilePath: imageFilePath,
requiredSync: true,
success: { db, updatedProfile in
if displayNameToBeUploaded != nil {
userDefaults[.lastDisplayNameUpdate] = Date()
}
if profilePictureToBeUploaded != nil {
if isUpdatingProfilePicture {
userDefaults[.lastProfilePictureUpdate] = Date()
}
@ -462,7 +474,6 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate {
threadVariant: .contact
)
self?.displayNameLabel.text = name
self?.profilePictureToBeUploaded = nil
self?.displayNameToBeUploaded = nil
}
}
@ -556,7 +567,12 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate {
}
isEditingDisplayName = false
displayNameToBeUploaded = displayName
updateProfile(isUpdatingDisplayName: true, isUpdatingProfilePicture: false)
updateProfile(
profilePicture: nil,
profilePictureFilePath: nil,
isUpdatingDisplayName: true,
isUpdatingProfilePicture: false
)
}
@objc private func showEditProfilePictureUI() {

@ -13,7 +13,7 @@ NS_ASSUME_NONNULL_BEGIN
- (nullable NSString *)avatarActionSheetTitle;
- (void)avatarDidChange:(UIImage *)image;
- (void)avatarDidChange:(nullable UIImage *)image filePath:(nullable NSString *)filePath;
- (UIViewController *)fromViewController;

@ -123,19 +123,34 @@ NS_ASSUME_NONNULL_BEGIN
[SNAppearance switchToSessionAppearance];
NSURL* imageURL = [info objectForKey:UIImagePickerControllerImageURL];
UIImage *rawAvatar = [info objectForKey:UIImagePickerControllerOriginalImage];
[self.delegate.fromViewController
dismissViewControllerAnimated:YES
completion:^{
OWSAssertIsOnMainThread();
// Check if the user selected an animated image (if so then don't crop, just
// set the avatar directly
NSString *type;
if ([imageURL getResourceValue:&type forKey:NSURLTypeIdentifierKey error:nil]) {
if ([[MIMETypeUtil supportedAnimatedImageUTITypes] containsObject:type]) {
dispatch_async(dispatch_get_main_queue(), ^{
[self.delegate avatarDidChange:nil filePath: imageURL.path];
});
return;
}
}
if (rawAvatar) {
OWSAssertIsOnMainThread();
CropScaleImageViewController *vc = [[CropScaleImageViewController alloc]
initWithSrcImage:rawAvatar
successCompletion:^(UIImage *_Nonnull dstImage) {
dispatch_async(dispatch_get_main_queue(), ^{
[self.delegate avatarDidChange:dstImage];
[self.delegate avatarDidChange:dstImage filePath:nil];
});
}];
[self.delegate.fromViewController presentViewController:vc

@ -17,8 +17,6 @@ import UIKit
internal func findFrontmostViewController(ignoringAlerts: Bool) -> UIViewController? {
guard let window: UIWindow = CurrentAppContext().mainWindow else { return nil }
Logger.error("findFrontmostViewController: \(window)")
guard let viewController: UIViewController = window.rootViewController else {
owsFailDebug("Missing root view controller.")
return nil

@ -123,7 +123,9 @@ enum _001_InitialSetupMigration: Migration {
t.column(.threadId, .text)
.notNull()
.primaryKey()
t.column(.server, .text).notNull()
t.column(.server, .text)
.indexed() // Quicker querying
.notNull()
t.column(.roomToken, .text).notNull()
t.column(.publicKey, .text).notNull()
t.column(.isActive, .boolean)
@ -328,10 +330,12 @@ enum _001_InitialSetupMigration: Migration {
.references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted
t.column(.authorId, .text)
.notNull()
.indexed() // Quicker querying
.references(Profile.self)
t.column(.timestampMs, .double).notNull()
t.column(.body, .text)
t.column(.attachmentId, .text)
.indexed() // Quicker querying
.references(Attachment.self, onDelete: .setNull) // Clear if attachment deleted
}
@ -345,6 +349,7 @@ enum _001_InitialSetupMigration: Migration {
t.column(.variant, .integer).notNull()
t.column(.title, .text)
t.column(.attachmentId, .text)
.indexed() // Quicker querying
.references(Attachment.self) // Managed via garbage collection
t.primaryKey([.url, .timestamp])

@ -21,19 +21,19 @@ enum _002_SetupStandardJobs: Migration {
_ = try Job(
variant: .disappearingMessages,
behaviour: .recurringOnLaunch,
shouldBlockFirstRunEachSession: true
shouldBlock: true
).inserted(db)
_ = try Job(
variant: .failedMessageSends,
behaviour: .recurringOnLaunch,
shouldBlockFirstRunEachSession: true
shouldBlock: true
).inserted(db)
_ = try Job(
variant: .failedAttachmentDownloads,
behaviour: .recurringOnLaunch,
shouldBlockFirstRunEachSession: true
shouldBlock: true
).inserted(db)
_ = try Job(

@ -18,6 +18,9 @@ enum _003_YDBToGRDBMigration: Migration {
static func migrate(_ db: Database) throws {
guard let dbConnection: YapDatabaseConnection = SUKLegacy.newDatabaseConnection() else {
// We want this setting to be on by default (even if there isn't a legacy database)
db[.trimOpenGroupMessagesOlderThanSixMonths] = true
SNLog("[Migration Warning] No legacy database, skipping \(target.key(with: self))")
return
}
@ -88,7 +91,10 @@ enum _003_YDBToGRDBMigration: Migration {
transaction.enumerateRows(inCollection: SMKLegacy.contactCollection) { _, object, _, _ in
guard let contact = object as? SMKLegacy._Contact else { return }
contacts.insert(contact)
/// Store a record of the all valid profiles (so we can create dummy entries if we need to for closed group members)
validProfileIds.insert(contact.sessionID)
}
@ -628,12 +634,28 @@ enum _003_YDBToGRDBMigration: Migration {
// Create the 'GroupMember' models for the group (even if the current user is no longer
// a member as these objects are used to generate the group avatar icon)
func createDummyProfile(profileId: String) {
SNLog("[Migration Warning] Closed group member with unknown user found - Creating empty profile")
// Note: Need to upsert here because it's possible multiple quotes
// will use the same invalid 'authorId' value resulting in a unique
// constraint violation
try? Profile(
id: profileId,
name: profileId
).save(db)
}
try groupModel.groupMemberIds.forEach { memberId in
try GroupMember(
groupId: threadId,
profileId: memberId,
role: .standard
).insert(db)
if !validProfileIds.contains(memberId) {
createDummyProfile(profileId: memberId)
}
}
try groupModel.groupAdminIds.forEach { adminId in
@ -642,6 +664,10 @@ enum _003_YDBToGRDBMigration: Migration {
profileId: adminId,
role: .admin
).insert(db)
if !validProfileIds.contains(adminId) {
createDummyProfile(profileId: adminId)
}
}
try (closedGroupZombieMemberIds[legacyThread.uniqueId] ?? []).forEach { zombieId in
@ -650,6 +676,10 @@ enum _003_YDBToGRDBMigration: Migration {
profileId: zombieId,
role: .zombie
).insert(db)
if !validProfileIds.contains(zombieId) {
createDummyProfile(profileId: zombieId)
}
}
}
@ -1438,6 +1468,9 @@ enum _003_YDBToGRDBMigration: Migration {
db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true)
db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions)
// We want this setting to be on by default
db[.trimOpenGroupMessagesOlderThanSixMonths] = true
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
}

@ -1049,7 +1049,10 @@ extension Attachment {
uploadPromise
.done(on: queue) { fileId in
// Save the final upload info
/// Save the final upload info
///
/// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is
/// updated correctly
let uploadedAttachment: Attachment? = Storage.shared.write { db in
try updatedAttachment?
.with(

@ -72,31 +72,6 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco
)
"""
}
// MARK: - PersistableRecord
public func save(_ db: Database) throws {
let oldProfile: Profile? = try? Profile.fetchOne(db, id: id)
try performSave(db)
db.afterNextTransactionCommit { db in
// Delete old profile picture if needed
if let oldProfilePictureFileName: String = oldProfile?.profilePictureFileName, oldProfilePictureFileName != profilePictureFileName {
let path: String = ProfileManager.profileAvatarFilepath(filename: oldProfilePictureFileName)
DispatchQueue.global(qos: .default).async {
OWSFileSystem.deleteFileIfExists(path)
}
}
// FIXME: Remove this once the OWSConversationSettingsViewController has been refactored and is observing DB changes
if id != getUserHexEncodedPublicKey(db) {
let userInfo = [ Notification.Key.profileRecipientId.rawValue: id ]
NotificationCenter.default.post(name: .otherUsersProfileDidChange, object: nil, userInfo: userInfo)
}
}
}
}
// MARK: - Codable

@ -174,7 +174,12 @@ public extension SessionThread {
(includeNonVisible || shouldBeVisible) &&
variant == .contact &&
id != getUserHexEncodedPublicKey(db) && // Note to self
(try? Contact.fetchOne(db, id: id))?.isApproved != true
(try? Contact
.filter(id: id)
.select(.isApproved)
.asRequest(of: Bool.self)
.fetchOne(db))
.defaulting(to: false) == false
)
}
}
@ -196,7 +201,7 @@ public extension SessionThread {
"""
}
static func unreadMessageRequestsThreadIdQuery(userPublicKey: String) -> SQLRequest<Int64> {
static func unreadMessageRequestsThreadIdQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest<String> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
@ -210,7 +215,7 @@ public extension SessionThread {
)
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
WHERE (
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey))
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible))
)
GROUP BY \(thread[.id])
"""
@ -245,6 +250,50 @@ public extension SessionThread {
)
}
func shouldShowNotification(_ db: Database, for interaction: Interaction, isMessageRequest: Bool) -> Bool {
// Ensure that the thread isn't muted and either the thread isn't only notifying for mentions
// or the user was actually mentioned
guard
Date().timeIntervalSince1970 > (self.mutedUntilTimestamp ?? 0) &&
(
self.variant == .contact ||
!self.onlyNotifyForMentions ||
interaction.hasMention
)
else { return false }
let userPublicKey: String = getUserHexEncodedPublicKey(db)
// No need to notify the user for self-send messages
guard interaction.authorId != userPublicKey else { return false }
// If the thread is a message request then we only want to notify for the first message
if self.variant == .contact && isMessageRequest {
let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests]
// If the user hasn't hidden the message requests section then only show the notification if
// all the other message request threads have been read
if !hasHiddenMessageRequests {
let numUnreadMessageRequestThreads: Int = (try? SessionThread
.unreadMessageRequestsThreadIdQuery(userPublicKey: userPublicKey, includeNonVisible: true)
.fetchCount(db))
.defaulting(to: 1)
guard numUnreadMessageRequestThreads == 1 else { return false }
}
// We only want to show a notification for the first interaction in the thread
guard ((try? self.interactions.fetchCount(db)) ?? 0) <= 1 else { return false }
// Need to re-show the message requests section if it had been hidden
if hasHiddenMessageRequests {
db[.hasHiddenMessageRequests] = false
}
}
return true
}
static func displayName(
threadId: String,
variant: Variant,

@ -1,21 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import SessionUtilitiesKit
// FIXME: Remove these extensions once the OWSConversationSettingsViewModel is refactored to swift and uses proper database observation
public extension Notification.Name {
static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange")
}
@objc public extension NSNotification {
@objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString
}
extension Notification.Key {
static let profileRecipientId = Notification.Key("profileRecipientId")
}
@objc public extension NSNotification {
static let profileRecipientIdKey = Notification.Key.profileRecipientId.rawValue as NSString
}

@ -16,6 +16,7 @@ public enum GarbageCollectionJob: JobExecutor {
public static var requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false
public static let approxSixMonthsInSeconds: TimeInterval = (6 * 30 * 24 * 60 * 60)
private static let minInteractionsToTrim: Int = 2000
public static func run(
_ job: Job,
@ -68,6 +69,8 @@ public enum GarbageCollectionJob: JobExecutor {
if typesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
let minInteractionsToTrimSql: SQL = SQL("\(GarbageCollectionJob.minInteractionsToTrim)")
try db.execute(literal: """
DELETE FROM \(Interaction.self)
@ -78,7 +81,17 @@ public enum GarbageCollectionJob: JobExecutor {
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND
\(thread[.id]) = \(interaction[.threadId])
)
WHERE \(interaction[.timestampMs]) < \(timestampNow - approxSixMonthsInSeconds)
JOIN (
SELECT
COUNT(\(interaction.alias[Column.rowID])) AS interactionCount,
\(interaction[.threadId])
FROM \(Interaction.self)
GROUP BY \(interaction[.threadId])
) AS interactionInfo ON interactionInfo.\(threadIdLiteral) = \(interaction[.threadId])
WHERE (
\(interaction[.timestampMs]) < \(timestampNow - approxSixMonthsInSeconds) AND
interactionInfo.interactionCount >= \(minInteractionsToTrimSql)
)
)
""")
}
@ -234,6 +247,41 @@ public enum GarbageCollectionJob: JobExecutor {
)
""")
}
if typesToCollect.contains(.orphanedProfiles) {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(Profile.self)
WHERE \(Column.rowID) IN (
SELECT \(profile.alias[Column.rowID])
FROM \(Profile.self)
LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(profile[.id])
LEFT JOIN \(Interaction.self) ON \(interaction[.authorId]) = \(profile[.id])
LEFT JOIN \(Quote.self) ON \(quote[.authorId]) = \(profile[.id])
LEFT JOIN \(GroupMember.self) ON \(groupMember[.profileId]) = \(profile[.id])
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(profile[.id])
LEFT JOIN \(BlindedIdLookup.self) ON (
blindedIdLookup.blindedId = \(profile[.id]) OR
blindedIdLookup.sessionId = \(profile[.id])
)
WHERE (
\(thread[.id]) IS NULL AND
\(interaction[.authorId]) IS NULL AND
\(quote[.authorId]) IS NULL AND
\(groupMember[.profileId]) IS NULL AND
\(contact[.id]) IS NULL AND
\(blindedIdLookup[.blindedId]) IS NULL
)
)
""")
}
},
completion: { _, _ in
// Dispatch async so we can swap from the write queue to a read one (we are done writing)
@ -353,6 +401,9 @@ public enum GarbageCollectionJob: JobExecutor {
return
}
// Update the 'lastGarbageCollection' date to prevent this job from running again
// for the next 23 hours
UserDefaults.standard[.lastGarbageCollection] = Date()
success(job, false)
}
}
@ -373,6 +424,7 @@ extension GarbageCollectionJob {
case orphanedOpenGroupCapabilities
case orphanedBlindedIdLookups
case approvedBlindedContactRecords
case orphanedProfiles
case orphanedAttachments
case orphanedAttachmentFiles
case orphanedProfileAvatars

@ -17,10 +17,7 @@ public enum NotifyPushServerJob: JobExecutor {
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
let server: String = PushNotificationAPI.server
guard
let url: URL = URL(string: "\(server)/notify"),
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
@ -28,34 +25,16 @@ public enum NotifyPushServerJob: JobExecutor {
return
}
let requestBody: RequestBody = RequestBody(
data: details.message.data.description,
sendTo: details.message.recipient
)
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
failure(job, HTTP.Error.invalidJSON, true)
return
}
var request: URLRequest = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
request.httpBody = body
attempt(maxRetryCount: 4, recoveringOn: queue) {
OnionRequestAPI
.sendOnionRequest(
request,
to: server,
using: .v2,
with: PushNotificationAPI.serverPublicKey
)
.map { _ in }
}
.done(on: queue) { _ in success(job, false) }
.catch(on: queue) { error in failure(job, error, false) }
.retainUntilComplete()
PushNotificationAPI
.notify(
recipient: details.message.recipient,
with: details.message.data,
maxRetryCount: 4,
queue: queue
)
.done(on: queue) { _ in success(job, false) }
.catch(on: queue) { error in failure(job, error, false) }
.retainUntilComplete()
}
}
@ -65,14 +44,4 @@ extension NotifyPushServerJob {
public struct Details: Codable {
public let message: SnodeMessage
}
struct RequestBody: Codable {
enum CodingKeys: String, CodingKey {
case data
case sendTo = "send_to"
}
let data: String
let sendTo: String
}
}

@ -34,12 +34,14 @@ public enum UpdateProfilePictureJob: JobExecutor {
// Note: The user defaults flag is updated in ProfileManager
let profile: Profile = Profile.fetchOrCreateCurrentUser()
let profilePicture: UIImage? = ProfileManager.profileAvatar(id: profile.id)
let profileFilePath: String? = profile.profilePictureFileName
.map { ProfileManager.profileAvatarFilepath(filename: $0) }
ProfileManager.updateLocal(
queue: queue,
profileName: profile.name,
avatarImage: profilePicture,
image: nil,
imageFilePath: profileFilePath,
requiredSync: true,
success: { _, _ in success(job, false) },
failure: { error in failure(job, error, false) }

@ -224,6 +224,15 @@ public enum MessageReceiver {
default: fatalError()
}
// Perform any required post-handling logic
try MessageReceiver.postHandleMessage(db, message: message, openGroupId: openGroupId)
}
public static func postHandleMessage(
_ db: Database,
message: Message,
openGroupId: String?
) throws {
// When handling any non-typing indicator message we want to make sure the thread becomes
// visible (the only other spot this flag gets set is when sending messages)
switch message {

@ -7,11 +7,21 @@ import SessionUtilitiesKit
@objc(LKPushNotificationAPI)
public final class PushNotificationAPI : NSObject {
struct RequestBody: Codable {
struct RegistrationRequestBody: Codable {
let token: String
let pubKey: String?
}
struct NotifyRequestBody: Codable {
enum CodingKeys: String, CodingKey {
case data
case sendTo = "send_to"
}
let data: String
let sendTo: String
}
struct ClosedGroupRequestBody: Codable {
let closedGroupPublicKey: String
let pubKey: String
@ -42,7 +52,7 @@ public final class PushNotificationAPI : NSObject {
// MARK: - Registration
public static func unregister(_ token: Data) -> Promise<Void> {
let requestBody: RequestBody = RequestBody(token: token.toHexString(), pubKey: nil)
let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: token.toHexString(), pubKey: nil)
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
return Promise(error: HTTP.Error.invalidJSON)
@ -92,7 +102,7 @@ public final class PushNotificationAPI : NSObject {
public static func register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> Promise<Void> {
let hexEncodedToken: String = token.toHexString()
let requestBody: RequestBody = RequestBody(token: hexEncodedToken, pubKey: publicKey)
let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: hexEncodedToken, pubKey: publicKey)
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
return Promise(error: HTTP.Error.invalidJSON)
@ -203,4 +213,33 @@ public final class PushNotificationAPI : NSObject {
public static func objc_performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> AnyPromise {
return AnyPromise.from(performOperation(operation, for: closedGroupPublicKey, publicKey: publicKey))
}
// MARK: - Notify
public static func notify(
recipient: String,
with message: String,
maxRetryCount: UInt? = nil,
queue: DispatchQueue = DispatchQueue.global()
) -> Promise<Void> {
let requestBody: NotifyRequestBody = NotifyRequestBody(data: message, sendTo: recipient)
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
return Promise(error: HTTP.Error.invalidJSON)
}
let url = URL(string: "\(server)/notify")!
var request: URLRequest = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
request.httpBody = body
let retryCount: UInt = (maxRetryCount ?? PushNotificationAPI.maxRetryCount)
let promise: Promise<Void> = attempt(maxRetryCount: retryCount, recoveringOn: queue) {
OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey)
.map { _ in }
}
return promise
}
}

@ -366,9 +366,10 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
// Only incoming messages
(self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) &&
// Show if the next message has a different sender or has a "date break"
// Show if the next message has a different sender, isn't a standard message or has a "date break"
(
self.authorId != nextModel?.authorId ||
(nextModel?.variant != .standardIncoming && nextModel?.variant != .standardIncomingDeleted) ||
shouldShowDateOnNextModel
) &&

@ -574,7 +574,6 @@ public extension SessionThreadViewModel {
(\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey),
(
\(thread[.shouldBeVisible]) = true AND
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND
\(SQL("\(thread[.id]) != \(userPublicKey)")) AND
IFNULL(\(contact[.isApproved]), false) = false

@ -12,7 +12,7 @@ public struct ProfileManager {
private static let nameDataLength: UInt = 26
public static let maxAvatarDiameter: CGFloat = 640
private static var profileAvatarCache: Atomic<[String: UIImage]> = Atomic([:])
private static var profileAvatarCache: Atomic<[String: Data]> = Atomic([:])
private static var currentAvatarDownloads: Atomic<Set<String>> = Atomic([])
// MARK: - Functions
@ -21,7 +21,7 @@ public struct ProfileManager {
return ((profileName.data(using: .utf8)?.count ?? 0) > nameDataLength)
}
public static func profileAvatar(_ db: Database? = nil, id: String) -> UIImage? {
public static func profileAvatar(_ db: Database? = nil, id: String) -> Data? {
guard let db: Database = db else {
return Storage.shared.read { db in profileAvatar(db, id: id) }
}
@ -30,9 +30,9 @@ public struct ProfileManager {
return profileAvatar(profile: profile)
}
public static func profileAvatar(profile: Profile) -> UIImage? {
public static func profileAvatar(profile: Profile) -> Data? {
if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty {
return loadProfileAvatar(for: profileFileName)
return loadProfileAvatar(for: profileFileName, profile: profile)
}
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
@ -42,22 +42,36 @@ public struct ProfileManager {
return nil
}
private static func loadProfileAvatar(for fileName: String) -> UIImage? {
if let cachedImage: UIImage = profileAvatarCache.wrappedValue[fileName] {
return cachedImage
private static func loadProfileAvatar(for fileName: String, profile: Profile) -> Data? {
if let cachedImageData: Data = profileAvatarCache.wrappedValue[fileName] {
return cachedImageData
}
guard
!fileName.isEmpty,
let data: Data = loadProfileData(with: fileName),
data.isValidImage,
let image: UIImage = UIImage(data: data)
data.isValidImage
else {
// If we can't load the avatar or it's an invalid/corrupted image then clear out
// the 'profilePictureFileName' and try to re-download
Storage.shared.writeAsync(
updates: { db in
_ = try? Profile
.filter(id: profile.id)
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil))
},
completion: { _, _ in
// Try to re-download the avatar if it has a URL
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
downloadAvatar(for: profile)
}
}
)
return nil
}
profileAvatarCache.mutate { $0[fileName] = image }
return image
profileAvatarCache.mutate { $0[fileName] = data }
return data
}
private static func loadProfileData(with fileName: String) -> Data? {
@ -98,6 +112,20 @@ public struct ProfileManager {
return path
}()
public static func profileAvatarFilepath(_ db: Database? = nil, id: String) -> String? {
guard let db: Database = db else {
return Storage.shared.read { db in profileAvatarFilepath(db, id: id) }
}
let maybeFileName: String? = try? Profile
.filter(id: id)
.select(.profilePictureFileName)
.asRequest(of: String.self)
.fetchOne(db)
return maybeFileName.map { ProfileManager.profileAvatarFilepath(filename: $0) }
}
public static func profileAvatarFilepath(filename: String) -> String {
guard !filename.isEmpty else { return "" }
@ -148,45 +176,46 @@ public struct ProfileManager {
.done(on: queue) { data in
currentAvatarDownloads.mutate { $0.remove(profile.id) }
Storage.shared.write { db in
guard let latestProfile: Profile = try Profile.fetchOne(db, id: profile.id) else {
return
}
guard
let latestProfileKey: OWSAES256Key = latestProfile.profileEncryptionKey,
!latestProfileKey.keyData.isEmpty,
latestProfileKey == profileKeyAtStart
else {
OWSLogger.warn("Ignoring avatar download for obsolete user profile.")
return
}
guard profileUrlStringAtStart == latestProfile.profilePictureUrl else {
OWSLogger.warn("Avatar url has changed during download.")
if latestProfile.profilePictureUrl?.isEmpty == false {
self.downloadAvatar(for: latestProfile)
}
return
}
guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else {
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
return
}
try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
guard let latestProfile: Profile = Storage.shared.read({ db in try Profile.fetchOne(db, id: profile.id) }) else {
return
}
guard
let latestProfileKey: OWSAES256Key = latestProfile.profileEncryptionKey,
!latestProfileKey.keyData.isEmpty,
latestProfileKey == profileKeyAtStart
else {
OWSLogger.warn("Ignoring avatar download for obsolete user profile.")
return
}
guard profileUrlStringAtStart == latestProfile.profilePictureUrl else {
OWSLogger.warn("Avatar url has changed during download.")
guard let image: UIImage = UIImage(contentsOfFile: filePath) else {
OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.")
return
if latestProfile.profilePictureUrl?.isEmpty == false {
self.downloadAvatar(for: latestProfile)
}
return
}
guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else {
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
return
}
try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
guard UIImage(contentsOfFile: filePath) != nil else {
OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.")
return
}
// Store the updated 'profilePictureFileName'
Storage.shared.write { db in
_ = try? Profile
.filter(id: profile.id)
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName))
profileAvatarCache.mutate { $0[fileName] = image }
profileAvatarCache.mutate { $0[fileName] = decryptedData }
}
// Redundant but without reading 'backgroundTask' it will warn that the variable
@ -209,7 +238,8 @@ public struct ProfileManager {
public static func updateLocal(
queue: DispatchQueue,
profileName: String,
avatarImage: UIImage?,
image: UIImage?,
imageFilePath: String?,
requiredSync: Bool,
success: ((Database, Profile) throws -> ())? = nil,
failure: ((ProfileManagerError) -> ())? = nil
@ -218,8 +248,60 @@ public struct ProfileManager {
// If the profile avatar was updated or removed then encrypt with a new profile key
// to ensure that other users know that our profile picture was updated
let newProfileKey: OWSAES256Key = OWSAES256Key.generateRandom()
let maxAvatarBytes: UInt = (5 * 1000 * 1000)
let avatarImageData: Data?
guard let avatarImage: UIImage = avatarImage else {
do {
avatarImageData = try {
guard var image: UIImage = image else {
guard let imageFilePath: String = imageFilePath else { return nil }
let data: Data = try Data(contentsOf: URL(fileURLWithPath: imageFilePath))
guard data.count <= maxAvatarBytes else {
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
// be able to fit our profile photo (eg. generating pure noise at our resolution
// compresses to ~200k)
SNLog("Animated profile avatar was too large.")
SNLog("Updating service with profile failed.")
throw ProfileManagerError.avatarUploadMaxFileSizeExceeded
}
return data
}
if image.size.width != maxAvatarDiameter || image.size.height != maxAvatarDiameter {
// To help ensure the user is being shown the same cropping of their avatar as
// everyone else will see, we want to be sure that the image was resized before this point.
SNLog("Avatar image should have been resized before trying to upload")
image = image.resizedImage(toFillPixelSize: CGSize(width: maxAvatarDiameter, height: maxAvatarDiameter))
}
guard let data: Data = image.jpegData(compressionQuality: 0.95) else {
SNLog("Updating service with profile failed.")
throw ProfileManagerError.avatarWriteFailed
}
guard data.count <= maxAvatarBytes else {
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
// be able to fit our profile photo (eg. generating pure noise at our resolution
// compresses to ~200k)
SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)")
SNLog("Updating service with profile failed.")
throw ProfileManagerError.avatarUploadMaxFileSizeExceeded
}
return data
}()
}
catch {
if let profileManagerError: ProfileManagerError = error as? ProfileManagerError {
failure?(profileManagerError)
}
return
}
guard let data: Data = avatarImageData else {
// If we have no image then we need to make sure to remove it from the profile
Storage.shared.writeAsync { db in
let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
@ -255,39 +337,18 @@ public struct ProfileManager {
// If we have a new avatar image, we must first:
//
// * Encode it to JPEG.
// * Write it to disk.
// * Encrypt it
// * Upload it to asset service
// * Send asset service info to Signal Service
OWSLogger.verbose("Updating local profile on service with new avatar.")
let maxAvatarBytes: UInt = (5 * 1000 * 1000)
var image: UIImage = avatarImage
if image.size.width != maxAvatarDiameter || image.size.height != maxAvatarDiameter {
// To help ensure the user is being shown the same cropping of their avatar as
// everyone else will see, we want to be sure that the image was resized before this point.
SNLog("Avatar image should have been resized before trying to upload")
image = image.resizedImage(toFillPixelSize: CGSize(width: maxAvatarDiameter, height: maxAvatarDiameter))
}
guard let data: Data = image.jpegData(compressionQuality: 0.95) else {
SNLog("Updating service with profile failed.")
failure?(.avatarWriteFailed)
return
}
guard data.count <= maxAvatarBytes else {
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
// be able to fit our profile photo (eg. generating pure noise at our resolution
// compresses to ~200k)
SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)")
SNLog("Updating service with profile failed.")
failure?(.avatarImageTooLarge)
return
}
let fileName: String = UUID().uuidString.appendingFileExtension("jpg")
let fileName: String = UUID().uuidString
.appendingFileExtension(
imageFilePath
.map { URL(fileURLWithPath: $0).pathExtension }
.defaulting(to: "jpg")
)
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
// Write the avatar to disk
@ -324,7 +385,7 @@ public struct ProfileManager {
.saved(db)
// Update the cached avatar image value
profileAvatarCache.mutate { $0[fileName] = avatarImage }
profileAvatarCache.mutate { $0[fileName] = data }
SNLog("Successfully updated service with profile.")
try success?(db, profile)

@ -10,43 +10,14 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol {
private var notifications: [String: UNNotificationRequest] = [:]
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
// If the thread is a message request and the user hasn't hidden message requests then we need
// to check if this is the only message request thread (group threads can't be message requests
// so just ignore those and if the user has hidden message requests then we want to show the
// notification regardless of how many message requests there are)
if thread.variant == .contact {
if isMessageRequest && !db[.hasHiddenMessageRequests] {
let numMessageRequestThreads: Int = (try? SessionThread
.messageRequestsQuery(userPublicKey: userPublicKey, includeNonVisible: true)
.fetchCount(db))
.defaulting(to: 0)
// Allow this to show a notification if there are no message requests (ie. this is the first one)
guard numMessageRequestThreads == 0 else { return }
}
else if isMessageRequest && db[.hasHiddenMessageRequests] {
// If there are other interactions on this thread already then don't show the notification
if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return }
db[.hasHiddenMessageRequests] = false
}
}
let senderPublicKey: String = interaction.authorId
guard senderPublicKey != userPublicKey else {
// Ignore PNs for messages sent by the current user
// after handling the message. Otherwise the closed
// group self-send messages won't show.
// Ensure we should be showing a notification for the thread
guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else {
return
}
let senderName: String = Profile.displayName(db, id: senderPublicKey, threadVariant: thread.variant)
let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant)
var notificationTitle: String = senderName
if thread.variant == .closedGroup || thread.variant == .openGroup {

@ -67,16 +67,26 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
return
}
let maybeVariant: SessionThread.Variant? = processedMessage.threadId
.map { threadId in
try? SessionThread
.filter(id: threadId)
.select(.variant)
.asRequest(of: SessionThread.Variant.self)
.fetchOne(db)
}
let isOpenGroup: Bool = (maybeVariant == .openGroup)
switch processedMessage.messageInfo.message {
case let visibleMessage as VisibleMessage:
let interactionId: Int64 = try MessageReceiver.handleVisibleMessage(
db,
message: visibleMessage,
associatedWithProto: processedMessage.proto,
openGroupId: nil,
openGroupId: (isOpenGroup ? processedMessage.threadId : nil),
isBackgroundPoll: false
)
// Remove the notifications if there is an outgoing messages from a linked device
if
let interaction: Interaction = try? Interaction.fetchOne(db, id: interactionId),
@ -127,6 +137,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
default: break
}
// Perform any required post-handling logic
try MessageReceiver.postHandleMessage(
db,
message: processedMessage.messageInfo.message,
openGroupId: (isOpenGroup ? processedMessage.threadId : nil)
)
}
catch {
if let error = error as? MessageReceiverError, error.isRetryable {

@ -14,10 +14,18 @@ enum _002_SetupStandardJobs: Migration {
static func migrate(_ db: Database) throws {
try autoreleasepool {
_ = try Job(
variant: .getSnodePool,
behaviour: .recurringOnLaunch,
shouldBlock: true
).inserted(db)
// Note: We also want this job to run both onLaunch and onActive as we want it to block
// 'onLaunch' and 'onActive' doesn't support blocking jobs
_ = try Job(
variant: .getSnodePool,
behaviour: .recurringOnActive,
shouldBlockFirstRunEachSession: true
shouldSkipLaunchBecomeActive: true
).inserted(db)
}

@ -16,26 +16,21 @@ internal extension OnionRequestAPI {
}
/// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination, with version: OnionRequestAPIVersion) -> Promise<AESGCM.EncryptionResult> {
static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) -> Promise<AESGCM.EncryptionResult> {
let (promise, seal) = Promise<AESGCM.EncryptionResult>.pending()
DispatchQueue.global(qos: .userInitiated).async {
do {
let data: Data
switch version {
case .v2, .v3:
// Wrapping is only needed for snode requests
switch destination {
case .snode: data = try encode(ciphertext: payload, json: [ "headers" : "" ])
case .server: data = payload
}
switch destination {
case .snode(let snode):
// Need to wrap the payload for snode requests
let data: Data = try encode(ciphertext: payload, json: [ "headers" : "" ])
let result: AESGCM.EncryptionResult = try AESGCM.encrypt(data, for: snode.x25519PublicKey)
seal.fulfill(result)
case .v4:
data = payload
case .server(_, _, let serverX25519PublicKey, _, _):
let result: AESGCM.EncryptionResult = try AESGCM.encrypt(payload, for: serverX25519PublicKey)
seal.fulfill(result)
}
let result = try encrypt(data, for: destination)
seal.fulfill(result)
}
catch (let error) {
seal.reject(error)
@ -44,16 +39,6 @@ internal extension OnionRequestAPI {
return promise
}
private static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) throws -> AESGCM.EncryptionResult {
switch destination {
case .snode(let snode):
return try AESGCM.encrypt(payload, for: snode.x25519PublicKey)
case .server(_, _, let serverX25519PublicKey, _, _):
return try AESGCM.encrypt(payload, for: serverX25519PublicKey)
}
}
/// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
static func encryptHop(from lhs: OnionRequestAPIDestination, to rhs: OnionRequestAPIDestination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise<AESGCM.EncryptionResult> {

@ -7,7 +7,7 @@ import PromiseKit
import SessionUtilitiesKit
public protocol OnionRequestAPIType {
static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String?) -> Promise<Data>
static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise<Data>
static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)>
}
@ -310,7 +310,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
}
/// Builds an onion around `payload` and returns the result.
private static func buildOnion(around payload: Data, targetedAt destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion) -> Promise<OnionBuildingResult> {
private static func buildOnion(around payload: Data, targetedAt destination: OnionRequestAPIDestination) -> Promise<OnionBuildingResult> {
var guardSnode: Snode!
var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination
var encryptionResult: AESGCM.EncryptionResult!
@ -323,7 +323,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
guardSnode = path.first!
// Encrypt in reverse order, i.e. the destination first
return encrypt(payload, for: destination, with: version)
return encrypt(payload, for: destination)
.then2 { r -> Promise<AESGCM.EncryptionResult> in
targetSnodeSymmetricKey = r.symmetricKey
@ -356,14 +356,15 @@ public enum OnionRequestAPI: OnionRequestAPIType {
// MARK: - Public API
/// Sends an onion request to `snode`. Builds new paths as needed.
public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String? = nil) -> Promise<Data> {
public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise<Data> {
let payloadJson: JSON = [ "method" : method.rawValue, "params" : parameters ]
guard let payload: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []) else {
return Promise(error: HTTP.Error.invalidJSON)
}
return sendOnionRequest(with: payload, to: OnionRequestAPIDestination.snode(snode), version: version)
/// **Note:** Currently the service nodes only support V3 Onion Requests
return sendOnionRequest(with: payload, to: OnionRequestAPIDestination.snode(snode), version: .v3)
.map { _, maybeData in
guard let data: Data = maybeData else { throw HTTP.Error.invalidResponse }
@ -410,42 +411,43 @@ public enum OnionRequestAPI: OnionRequestAPIType {
var guardSnode: Snode?
Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths`
buildOnion(around: payload, targetedAt: destination, version: version).done2 { intermediate in
guardSnode = intermediate.guardSnode
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"
let finalEncryptionResult = intermediate.finalEncryptionResult
let onion = finalEncryptionResult.ciphertext
if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) {
SNLog("Approaching request size limit: ~\(onion.count) bytes.")
}
let parameters: JSON = [
"ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString()
]
let body: Data
do {
body = try encode(ciphertext: onion, json: parameters)
} catch {
return seal.reject(error)
}
let destinationSymmetricKey = intermediate.destinationSymmetricKey
HTTP.execute(.post, url, body: body)
.done2 { responseData in
handleResponse(
responseData: responseData,
destinationSymmetricKey: destinationSymmetricKey,
version: version,
destination: destination,
seal: seal
)
buildOnion(around: payload, targetedAt: destination)
.done2 { intermediate in
guardSnode = intermediate.guardSnode
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"
let finalEncryptionResult = intermediate.finalEncryptionResult
let onion = finalEncryptionResult.ciphertext
if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) {
SNLog("Approaching request size limit: ~\(onion.count) bytes.")
}
.catch2 { error in
seal.reject(error)
let parameters: JSON = [
"ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString()
]
let body: Data
do {
body = try encode(ciphertext: onion, json: parameters)
} catch {
return seal.reject(error)
}
}
.catch2 { error in
seal.reject(error)
}
let destinationSymmetricKey = intermediate.destinationSymmetricKey
HTTP.execute(.post, url, body: body)
.done2 { responseData in
handleResponse(
responseData: responseData,
destinationSymmetricKey: destinationSymmetricKey,
version: version,
destination: destination,
seal: seal
)
}
.catch2 { error in
seal.reject(error)
}
}
.catch2 { error in
seal.reject(error)
}
}
promise.catch2 { error in // Must be invoked on Threading.workQueue

@ -134,7 +134,6 @@ public final class SnodeAPI {
to: snode,
invoking: method,
with: parameters,
using: .v3,
associatedWith: publicKey
)
.map2 { responseData in
@ -207,7 +206,6 @@ public final class SnodeAPI {
attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) {
HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true)
.map2 { responseData -> Set<Snode> in
// TODO: Validate this works
guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else {
throw SnodeAPIError.snodePoolUpdatingFailed
}
@ -261,7 +259,6 @@ public final class SnodeAPI {
return invoke(.oxenDaemonRPCCall, on: snode, parameters: parameters)
.map2 { responseData in
// TODO: Validate this works
guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else {
throw SnodeAPIError.snodePoolUpdatingFailed
}

@ -31,10 +31,13 @@ enum _001_InitialSetupMigration: Migration {
t.column(.behaviour, .integer)
.notNull()
.indexed() // Quicker querying
t.column(.shouldBlockFirstRunEachSession, .boolean)
t.column(.shouldBlock, .boolean)
.notNull()
.indexed() // Quicker querying
.defaults(to: false)
t.column(.shouldSkipLaunchBecomeActive, .boolean)
.notNull()
.defaults(to: false)
t.column(.nextRunTimestamp, .double)
.notNull()
.indexed() // Quicker querying

@ -25,7 +25,8 @@ enum _002_SetupStandardJobs: Migration {
// in 'onActive' (see the `SyncPushTokensJob` for more info)
_ = try Job(
variant: .syncPushTokens,
behaviour: .recurringOnActive
behaviour: .recurringOnActive,
shouldSkipLaunchBecomeActive: true
).inserted(db)
}

@ -31,7 +31,8 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
case failureCount
case variant
case behaviour
case shouldBlockFirstRunEachSession
case shouldBlock
case shouldSkipLaunchBecomeActive
case nextRunTimestamp
case threadId
case interactionId
@ -136,12 +137,16 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
/// How the job should behave
public let behaviour: Behaviour
/// When the app starts or returns from the background this flag controls whether the job should prevent other
/// jobs from starting until after it completes
/// When the app starts this flag controls whether the job should prevent other jobs from starting until after it completes
///
/// **Note:** `OnLaunch` blocking jobs will be started on launch and all others will be triggered when becoming
/// active but the "blocking" behaviour will only occur if there are no other jobs already running
public let shouldBlockFirstRunEachSession: Bool
/// **Note:** This flag is only supported for jobs with an `OnLaunch` behaviour because there is no way to guarantee
/// jobs with any other behaviours will be added to the JobRunner before all the `OnLaunch` blocking jobs are completed
/// resulting in the JobRunner no longer blocking
public let shouldBlock: Bool
/// When the app starts it also triggers any `OnActive` jobs, this flag controls whether the job should skip this initial `OnActive`
/// trigger (generally used for the same job registered with both `OnLaunch` and `OnActive` behaviours)
public let shouldSkipLaunchBecomeActive: Bool
/// Seconds since epoch to indicate the next datetime that this job should run
public let nextRunTimestamp: TimeInterval
@ -184,17 +189,25 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
failureCount: UInt,
variant: Variant,
behaviour: Behaviour,
shouldBlockFirstRunEachSession: Bool,
shouldBlock: Bool,
shouldSkipLaunchBecomeActive: Bool,
nextRunTimestamp: TimeInterval,
threadId: String?,
interactionId: Int64?,
details: Data?
) {
Job.ensureValidBehaviour(
behaviour: behaviour,
shouldBlock: shouldBlock,
shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive
)
self.id = id
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession
self.shouldBlock = shouldBlock
self.shouldSkipLaunchBecomeActive = shouldSkipLaunchBecomeActive
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.interactionId = interactionId
@ -205,15 +218,23 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
failureCount: UInt = 0,
variant: Variant,
behaviour: Behaviour = .runOnce,
shouldBlockFirstRunEachSession: Bool = false,
shouldBlock: Bool = false,
shouldSkipLaunchBecomeActive: Bool = false,
nextRunTimestamp: TimeInterval = 0,
threadId: String? = nil,
interactionId: Int64? = nil
) {
Job.ensureValidBehaviour(
behaviour: behaviour,
shouldBlock: shouldBlock,
shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive
)
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession
self.shouldBlock = shouldBlock
self.shouldSkipLaunchBecomeActive = shouldSkipLaunchBecomeActive
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.interactionId = interactionId
@ -224,13 +245,19 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
failureCount: UInt = 0,
variant: Variant,
behaviour: Behaviour = .runOnce,
shouldBlockFirstRunEachSession: Bool = false,
shouldBlock: Bool = false,
shouldSkipLaunchBecomeActive: Bool = false,
nextRunTimestamp: TimeInterval = 0,
threadId: String? = nil,
interactionId: Int64? = nil,
details: T?
) {
precondition(T.self != Job.self, "[Job] Fatal error trying to create a Job with a Job as it's details")
Job.ensureValidBehaviour(
behaviour: behaviour,
shouldBlock: shouldBlock,
shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive
)
guard
let details: T = details,
@ -240,13 +267,31 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession
self.shouldBlock = shouldBlock
self.shouldSkipLaunchBecomeActive = shouldSkipLaunchBecomeActive
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.interactionId = interactionId
self.details = detailsData
}
fileprivate static func ensureValidBehaviour(
behaviour: Behaviour,
shouldBlock: Bool,
shouldSkipLaunchBecomeActive: Bool
) {
// Blocking jobs can only run on launch as we can't guarantee that any other behaviours will get added
// to the JobRunner before any prior blocking jobs have completed (resulting in them being non-blocking)
precondition(
!shouldBlock || behaviour == .recurringOnLaunch || behaviour == .runOnceNextLaunch,
"[Job] Fatal error trying to create a blocking job which doesn't run on launch"
)
precondition(
!shouldSkipLaunchBecomeActive || behaviour == .recurringOnActive,
"[Job] Fatal error trying to create a job which skips on 'OnActive' triggered during launch with doesn't run on active"
)
}
// MARK: - Custom Database Interaction
public mutating func didInsert(with rowID: Int64, for column: String?) {
@ -306,7 +351,8 @@ public extension Job {
failureCount: failureCount,
variant: self.variant,
behaviour: self.behaviour,
shouldBlockFirstRunEachSession: self.shouldBlockFirstRunEachSession,
shouldBlock: self.shouldBlock,
shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive,
nextRunTimestamp: nextRunTimestamp,
threadId: self.threadId,
interactionId: self.interactionId,
@ -322,7 +368,8 @@ public extension Job {
failureCount: self.failureCount,
variant: self.variant,
behaviour: self.behaviour,
shouldBlockFirstRunEachSession: self.shouldBlockFirstRunEachSession,
shouldBlock: self.shouldBlock,
shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive,
nextRunTimestamp: self.nextRunTimestamp,
threadId: self.threadId,
interactionId: self.interactionId,

@ -100,7 +100,7 @@ public final class Storage {
migrations: [TargetMigrations],
async: Bool = true,
onProgressUpdate: ((CGFloat, TimeInterval) -> ())?,
onComplete: @escaping (Bool, Bool) -> ()
onComplete: @escaping (Error?, Bool) -> ()
) {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
@ -176,7 +176,7 @@ public final class Storage {
}
// Store the logic to run when the migration completes
let migrationCompleted: (Error?) -> () = { [weak self] error in
let migrationCompleted: (Database, Error?) -> () = { [weak self] db, error in
self?.hasCompletedMigrations = true
self?.migrationProgressUpdater = nil
SUKLegacy.clearLegacyDatabaseInstance()
@ -186,18 +186,27 @@ public final class Storage {
SNLog("[Migration Error] Migration failed with error: \(error)")
}
onComplete((error == nil), needsConfigSync)
// TODO: Remove this once everyone has updated
var finalError: Error? = error
let jobTableInfo: [Row] = (try? Row.fetchAll(db, sql: "PRAGMA table_info(\(Job.databaseTableName))"))
.defaulting(to: [])
if !jobTableInfo.contains(where: { $0["name"] == "shouldSkipLaunchBecomeActive" }) {
finalError = StorageError.devRemigrationRequired
}
// TODO: Remove this once everyone has updated
onComplete(finalError, needsConfigSync)
}
// Note: The non-async migration should only be used for unit tests
guard async else {
do { try self.migrator?.migrate(dbWriter) }
catch { migrationCompleted(error) }
catch { try? dbWriter.read { db in migrationCompleted(db, error) } }
return
}
self.migrator?.asyncMigrate(dbWriter) { _, error in
migrationCompleted(error)
self.migrator?.asyncMigrate(dbWriter) { db, error in
migrationCompleted(db, error)
}
}
@ -249,7 +258,6 @@ public final class Storage {
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
try SSKDefaultKeychainStorage.shared.set(data: keySpec, service: keychainService, key: dbCipherKeySpecKey)
print("RAWR new keySpec generated and saved")
return keySpec
}
catch {

@ -14,4 +14,6 @@ public enum StorageError: Error {
case objectNotSaved
case invalidSearchPattern
case devRemigrationRequired
}

@ -102,6 +102,7 @@ public final class JobRunner {
internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:])
fileprivate static var perSessionJobsCompleted: Atomic<Set<Int64>> = Atomic([])
private static var hasCompletedInitialBecomeActive: Atomic<Bool> = Atomic(false)
// MARK: - Configuration
@ -184,7 +185,7 @@ public final class JobRunner {
Job.Behaviour.runOnceNextLaunch
].contains(Job.Columns.behaviour)
)
.filter(Job.Columns.shouldBlockFirstRunEachSession == true)
.filter(Job.Columns.shouldBlock == true)
.order(Job.Columns.id)
.fetchAll(db)
let nonblockingJobs: [Job] = try Job
@ -194,7 +195,7 @@ public final class JobRunner {
Job.Behaviour.runOnceNextLaunch
].contains(Job.Columns.behaviour)
)
.filter(Job.Columns.shouldBlockFirstRunEachSession == false)
.filter(Job.Columns.shouldBlock == false)
.order(Job.Columns.id)
.fetchAll(db)
@ -218,65 +219,38 @@ public final class JobRunner {
}
public static func appDidBecomeActive() {
// Note: When becoming active we want to start all non-on-launch blocking jobs as
// long as there are no other jobs already running
let alreadyRunningOtherJobs: Bool = queues.wrappedValue
.contains(where: { _, queue -> Bool in queue.isRunning.wrappedValue })
let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = Storage.shared
let hasCompletedInitialBecomeActive: Bool = JobRunner.hasCompletedInitialBecomeActive.wrappedValue
let jobsToRun: [Job] = Storage.shared
.read { db in
guard !alreadyRunningOtherJobs else {
let onActiveJobs: [Job] = try Job
.filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive)
.order(Job.Columns.id)
.fetchAll(db)
return ([], onActiveJobs)
}
let blockingJobs: [Job] = try Job
.filter(
Job.Behaviour.allCases
.filter {
$0 != .recurringOnLaunch &&
$0 != .runOnceNextLaunch
}
.contains(Job.Columns.behaviour)
)
.filter(Job.Columns.shouldBlockFirstRunEachSession == true)
.order(Job.Columns.id)
.fetchAll(db)
let nonBlockingJobs: [Job] = try Job
return try Job
.filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive)
.filter(Job.Columns.shouldBlockFirstRunEachSession == false)
.order(Job.Columns.id)
.fetchAll(db)
return (blockingJobs, nonBlockingJobs)
}
.defaulting(to: ([], []))
.defaulting(to: [])
.filter { hasCompletedInitialBecomeActive || !$0.shouldSkipLaunchBecomeActive }
// Store the current queue state locally to avoid multiple atomic retrievals
let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue
let blockingQueueIsRunning: Bool = (blockingQueue.wrappedValue?.isRunning.wrappedValue == true)
guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else {
guard !jobsToRun.isEmpty else {
if !blockingQueueIsRunning {
jobQueues.forEach { _, queue in queue.start() }
}
return
}
// Add and start any blocking jobs
blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true)
// Add and start any non-blocking jobs (if there are no blocking jobs)
let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.nonBlocking.grouped(by: \.variant)
let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.grouped(by: \.variant)
jobQueues.forEach { variant, queue in
queue.appDidBecomeActive(
with: (jobsByVariant[variant] ?? []),
canStart: (!blockingQueueIsRunning && jobsToRun.blocking.isEmpty)
canStart: !blockingQueueIsRunning
)
}
JobRunner.hasCompletedInitialBecomeActive.mutate { $0 = true }
}
public static func isCurrentlyRunning(_ job: Job?) -> Bool {
@ -849,7 +823,7 @@ private final class JobQueue {
}
// If this is the blocking queue and a "blocking" job failed then rerun it immediately
if self.type == .blocking && job.shouldBlockFirstRunEachSession {
if self.type == .blocking && job.shouldBlock {
SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; retrying immediately")
jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) }
detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) }

@ -2,6 +2,7 @@
import UIKit
import GRDB
import YYImage
import SessionUIKit
import SessionMessagingKit
@ -112,8 +113,8 @@ public final class ProfilePictureView: UIView {
guard !publicKey.isEmpty || openGroupProfilePicture != nil else { return }
func getProfilePicture(of size: CGFloat, for publicKey: String, profile: Profile?) -> (image: UIImage, isTappable: Bool) {
if let profile: Profile = profile, let profilePicture: UIImage = ProfileManager.profileAvatar(profile: profile) {
return (profilePicture, true)
if let profile: Profile = profile, let profileData: Data = ProfileManager.profileAvatar(profile: profile), let image: YYImage = YYImage(data: profileData) {
return (image, true)
}
return (
@ -179,7 +180,7 @@ public final class ProfilePictureView: UIView {
hasTappableProfilePicture = isTappable
}
imageView.contentMode = .scaleAspectFit
imageView.contentMode = .scaleAspectFill
imageView.backgroundColor = Colors.unimportant
imageView.layer.cornerRadius = (targetSize / 2)
additionalImageView.layer.cornerRadius = (targetSize / 2)
@ -187,11 +188,11 @@ public final class ProfilePictureView: UIView {
// MARK: - Convenience
private func getImageView() -> UIImageView {
let result = UIImageView()
private func getImageView() -> YYAnimatedImageView {
let result = YYAnimatedImageView()
result.layer.masksToBounds = true
result.backgroundColor = Colors.unimportant
result.contentMode = .scaleAspectFit
result.contentMode = .scaleAspectFill
return result
}

@ -11,7 +11,7 @@ public enum AppSetup {
public static func setupEnvironment(
appSpecificBlock: @escaping () -> (),
migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil,
migrationsCompletion: @escaping (Bool, Bool) -> ()
migrationsCompletion: @escaping (Error?, Bool) -> ()
) {
guard !AppSetup.hasRun else { return }
@ -60,7 +60,7 @@ public enum AppSetup {
public static func runPostSetupMigrations(
backgroundTask: OWSBackgroundTask? = nil,
migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil,
migrationsCompletion: @escaping (Bool, Bool) -> ()
migrationsCompletion: @escaping (Error?, Bool) -> ()
) {
var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function))
@ -71,9 +71,9 @@ public enum AppSetup {
SNMessagingKit.migrations()
],
onProgressUpdate: migrationProgressChanged,
onComplete: { success, needsConfigSync in
onComplete: { error, needsConfigSync in
DispatchQueue.main.async {
migrationsCompletion(success, needsConfigSync)
migrationsCompletion(error, needsConfigSync)
// The 'if' is only there to prevent the "variable never read" warning from showing
if backgroundTask != nil { backgroundTask = nil }

Loading…
Cancel
Save