Fixed a few closed group and job issues

Fixed a few job migration issues
Fixed an issue with the closed group key pair management (wasn't storing keys correctly)
Refactored the OWSSound (now Preferences.Sound)
Added the logic for the AttachmentDownloadJob and enabled jobs to be cascade deleted via interactions
Optimised the HomeViewModel database observation query (fetch specific columns so changes outside those don't trigger updates)
Updated to the latest GRDB (ran into a deadlock which should be fixed in a newer version)
pull/612/head
Morgan Pretty 2 years ago
parent 11231599db
commit ed9f4ea6c6

@ -27,7 +27,7 @@ PODS:
- DifferenceKit/Core (1.2.0)
- DifferenceKit/UIKitExtension (1.2.0):
- DifferenceKit/Core
- GRDB.swift/SQLCipher (5.19.0):
- GRDB.swift/SQLCipher (5.23.0):
- SQLCipher (>= 3.4.0)
- Mantle (2.1.0):
- Mantle/extobjc (= 2.1.0)
@ -203,7 +203,7 @@ SPEC CHECKSUMS:
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805
GRDB.swift: c00ff42d3cffbe90145fb4e364e26a099f997142
GRDB.swift: e4a950fe99d113ea5d24571d49eaae0062303c14
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2

@ -121,7 +121,6 @@
70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; };
768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; };
76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; };
7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E1271E743B00848B49 /* OWSSounds.swift */; };
7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; };
7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; };
7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */; };
@ -213,8 +212,6 @@
B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */; };
B8856E1A256F1700001CE70E /* OWSMath.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB14255A580800E217F9 /* OWSMath.h */; settings = {ATTRIBUTES = (Public, ); }; };
B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */; settings = {ATTRIBUTES = (Public, ); }; };
B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF28B255B6D86007E1867 /* OWSSounds.m */; };
B8856E9D256F1C3D001CE70E /* OWSSounds.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF288255B6D85007E1867 /* OWSSounds.h */; settings = {ATTRIBUTES = (Public, ); }; };
B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF308255B6DBE007E1867 /* OWSPreferences.m */; };
B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; };
B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; };
@ -1136,7 +1133,6 @@
768A1A2A17FC9CD300E00ED8 /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = usr/lib/libz.dylib; sourceTree = SDKROOT; };
76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; };
7ABE4694B110C1BBCB0E46A2 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = "<group>"; };
7B1581E1271E743B00848B49 /* OWSSounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSounds.swift; sourceTree = "<group>"; };
7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = "<group>"; };
7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Timeout.swift"; sourceTree = "<group>"; };
7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = "<group>"; };
@ -1574,8 +1570,6 @@
C38EF284255B6D84007E1867 /* AppSetup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppSetup.h; path = SignalUtilitiesKit/Utilities/AppSetup.h; sourceTree = SOURCE_ROOT; };
C38EF286255B6D85007E1867 /* VersionMigrations.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = VersionMigrations.m; path = SignalUtilitiesKit/Utilities/VersionMigrations.m; sourceTree = SOURCE_ROOT; };
C38EF287255B6D85007E1867 /* AppSetup.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppSetup.m; path = SignalUtilitiesKit/Utilities/AppSetup.m; sourceTree = SOURCE_ROOT; };
C38EF288255B6D85007E1867 /* OWSSounds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSSounds.h; path = SessionMessagingKit/Utilities/OWSSounds.h; sourceTree = SOURCE_ROOT; };
C38EF28B255B6D86007E1867 /* OWSSounds.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSSounds.m; path = SessionMessagingKit/Utilities/OWSSounds.m; sourceTree = SOURCE_ROOT; };
C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Identicon+ObjC.swift"; path = "SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift"; sourceTree = SOURCE_ROOT; };
C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PlaceholderIcon.swift; path = "SignalUtilitiesKit/Profile Pictures/PlaceholderIcon.swift"; sourceTree = SOURCE_ROOT; };
C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProfilePictureView.swift; path = "SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift"; sourceTree = SOURCE_ROOT; };
@ -2865,7 +2859,6 @@
C352A3922557883D00338F3E /* JobDelegate.swift */,
C352A3882557876500338F3E /* JobQueue.swift */,
C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */,
C352A348255781F400338F3E /* AttachmentDownloadJob.swift */,
);
path = Jobs;
sourceTree = "<group>";
@ -3244,9 +3237,6 @@
C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */,
C38EF308255B6DBE007E1867 /* OWSPreferences.m */,
FDF0B75D280AAF35004C14C5 /* Preferences.swift */,
C38EF288255B6D85007E1867 /* OWSSounds.h */,
C38EF28B255B6D86007E1867 /* OWSSounds.m */,
7B1581E1271E743B00848B49 /* OWSSounds.swift */,
C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */,
C38EF306255B6DBE007E1867 /* OWSWindowManager.m */,
FD09797327FAB3E200936362 /* ProfileManager.swift */,
@ -3821,6 +3811,7 @@
C352A31225574F5200338F3E /* MessageReceiveJob.swift */,
C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */,
FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */,
C352A348255781F400338F3E /* AttachmentDownloadJob.swift */,
);
path = Types;
sourceTree = "<group>";
@ -3978,7 +3969,6 @@
C3A3A15F256E1BB4004D228D /* ProtoUtils.h in Headers */,
C3A3A145256E1B49004D228D /* OWSMediaGalleryFinder.h in Headers */,
B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */,
B8856E9D256F1C3D001CE70E /* OWSSounds.h in Headers */,
C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -4941,7 +4931,6 @@
FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */,
FD09799727FFA84A00936362 /* RecipientState.swift in Sources */,
C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */,
7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */,
C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */,
FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */,
FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */,
@ -5025,7 +5014,6 @@
C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */,
C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */,
C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */,
B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */,
C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */,
C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */,
FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */,

@ -173,20 +173,21 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
let selectedContacts = self.selectedContacts
let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
var promise: Promise<TSGroupThread>!
Storage.writeSync { transaction in
promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction)
let promise: Promise<SessionThread> = GRDBStorage.shared.write { db in
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
}
let _ = promise.done(on: DispatchQueue.main) { thread in
GRDBStorage.shared.write { db in
MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
self?.presentingViewController?.dismiss(animated: true, completion: nil)
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
}
promise.catch(on: DispatchQueue.main) { _ in
promise.catch(on: DispatchQueue.main) { [weak self] _ in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
let title = "Couldn't Create Group"
let message = "Please check your internet connection and try again."
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)

@ -47,7 +47,6 @@
#import <SessionMessagingKit/OWSPreferences.h>
#import <SignalUtilitiesKit/OWSProfileManager.h>
#import <SessionMessagingKit/OWSQuotedReplyModel.h>
#import <SessionMessagingKit/OWSSounds.h>
#import <SignalUtilitiesKit/OWSViewController.h>
#import <SignalUtilitiesKit/UIColor+OWS.h>
#import <SignalUtilitiesKit/UIFont+OWS.h>

@ -95,8 +95,8 @@ protocol NotificationPresenterAdaptee: AnyObject {
func registerNotificationSettings() -> Promise<Void>
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?)
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?)
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?)
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?)
func cancelNotifications(threadId: String)
func cancelNotifications(identifiers: [String])
@ -225,15 +225,11 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
)
)
}
default:
notificationTitle = "Session"
}
switch previewType {
case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody
case .nameAndPreview: notificationBody = messageText
default: notificationBody = NotificationStrings.incomingMessageBody
}
// If it's a message request then overwrite the body to be something generic (only show a notification
@ -257,7 +253,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
in: (notificationBody ?? ""),
threadId: thread.id
)
let sound = self.requestSound(thread: thread)
let sound: Preferences.Sound? = self.requestSound(thread: thread)
self.adaptee.notify(
category: category,
@ -286,7 +282,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
]
DispatchQueue.main.async {
let sound = self.requestSound(thread: thread)
let sound: Preferences.Sound? = self.requestSound(thread: thread)
self.adaptee.notify(
category: .errorMessage,
title: notificationTitle,
@ -318,12 +315,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
var mostRecentNotifications = TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)
private func requestSound(thread: SessionThread) -> OWSSound? {
private func requestSound(thread: SessionThread) -> Preferences.Sound? {
guard checkIfShouldPlaySound() else {
return nil
}
return OWSSounds.notificationSound(forThreadId: thread.id)
return thread.notificationSound
}
private func checkIfShouldPlaySound() -> Bool {

@ -10,6 +10,7 @@ import SessionUtilitiesKit
public enum SyncPushTokensJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false
public static func run(
_ job: Job,

@ -5,6 +5,7 @@
import Foundation
import UserNotifications
import PromiseKit
import SessionMessagingKit
class UserNotificationConfig {
@ -85,12 +86,12 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
}
}
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?) {
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?) {
AssertIsOnMainThread()
notify(category: category, title: title, body: body, userInfo: userInfo, sound: sound, replacingIdentifier: nil)
}
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?) {
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?) {
AssertIsOnMainThread()
let content = UNMutableNotificationContent()
@ -103,7 +104,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
isBackgroudPoll = replacingIdentifier == threadIdentifier
}
let isAppActive = UIApplication.shared.applicationState == .active
if let sound = sound, sound != OWSSound.none {
if let sound = sound, sound != .none {
content.sound = sound.notificationSound(isQuiet: isAppActive)
}

@ -9,7 +9,7 @@
#import "OWSSoundSettingsViewController.h"
#import <SessionMessagingKit/Environment.h>
#import <SessionMessagingKit/OWSPreferences.h>
#import <SessionMessagingKit/OWSSounds.h>
#import <SessionMessagingKit/SessionMessagingKit-Swift.h>
#import <SignalUtilitiesKit/UIUtil.h>
#import "Session-Swift.h"
@ -66,7 +66,7 @@
addItem:[OWSTableItem disclosureItemWithText:
NSLocalizedString(@"SETTINGS_ITEM_NOTIFICATION_SOUND",
@"Label for settings view that allows user to change the notification sound.")
detailText:[OWSSounds displayNameForSound:[OWSSounds globalNotificationSound]]
detailText:[SMKSound displayNameFor:[SMKSound defaultNotificationSound]]
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"message_sound")
actionBlock:^{
OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new];

@ -12,7 +12,7 @@ NS_ASSUME_NONNULL_BEGIN
// This property is optional. If it is not set, we are
// editing the global notification sound.
@property (nonatomic, nullable) TSThread *thread;
@property (nonatomic, nullable) NSString *threadId;
@end

@ -5,7 +5,7 @@
#import "OWSSoundSettingsViewController.h"
#import <AVFoundation/AVFoundation.h>
#import <SessionMessagingKit/OWSAudioPlayer.h>
#import <SessionMessagingKit/OWSSounds.h>
#import <SessionMessagingKit/SessionMessagingKit-Swift.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
#import <SignalUtilitiesKit/UIUtil.h>
#import "Session-Swift.h"
@ -16,7 +16,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic) BOOL isDirty;
@property (nonatomic) OWSSound currentSound;
@property (nonatomic) NSInteger currentSound;
@property (nonatomic, nullable) OWSAudioPlayer *audioPlayer;
@ -32,9 +32,8 @@ NS_ASSUME_NONNULL_BEGIN
[self setTitle:NSLocalizedString(@"SETTINGS_ITEM_NOTIFICATION_SOUND",
@"Label for settings view that allows user to change the notification sound.")];
self.currentSound
= (self.thread ? [OWSSounds notificationSoundForThread:self.thread] : [OWSSounds globalNotificationSound]);
self.currentSound = [SMKSound notificationSoundFor:self.threadId];
[self updateTableContents];
[self updateNavigationItems];
@ -85,33 +84,34 @@ NS_ASSUME_NONNULL_BEGIN
soundsSection.headerTitle = NSLocalizedString(
@"NOTIFICATIONS_SECTION_SOUNDS", @"Label for settings UI that allows user to change the notification sound.");
NSArray<NSNumber *> *allSounds = [OWSSounds allNotificationSounds];
NSArray<NSNumber *> *allSounds = [SMKSound notificationSounds];
for (NSNumber *nsValue in allSounds) {
OWSSound sound = (OWSSound)nsValue.intValue;
NSInteger sound = nsValue.integerValue;
OWSTableItem *item;
NSString *soundLabelText = ^{
NSString *baseName = [OWSSounds displayNameForSound:sound];
if (sound == OWSSound_Note) {
NSString *baseName = [SMKSound displayNameFor:sound];
if ([SMKSound isNote:sound]) {
NSString *noteStringFormat = NSLocalizedString(@"SETTINGS_AUDIO_DEFAULT_TONE_LABEL_FORMAT",
@"Format string for the default 'Note' sound. Embeds the system {{sound name}}.");
return [NSString stringWithFormat:noteStringFormat, baseName];
} else {
return [OWSSounds displayNameForSound:sound];
}
else {
return [SMKSound displayNameFor:sound];
}
}();
if (sound == self.currentSound) {
item = [OWSTableItem
checkmarkItemWithText:soundLabelText
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [OWSSounds displayNameForSound:sound])
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [SMKSound displayNameFor:sound])
actionBlock:^{
[weakSelf soundWasSelected:sound];
}];
} else {
item = [OWSTableItem
actionItemWithText:soundLabelText
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [OWSSounds displayNameForSound:sound])
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [SMKSound displayNameFor:sound])
actionBlock:^{
[weakSelf soundWasSelected:sound];
}];
@ -126,10 +126,10 @@ NS_ASSUME_NONNULL_BEGIN
#pragma mark - Events
- (void)soundWasSelected:(OWSSound)sound
- (void)soundWasSelected:(NSInteger)sound
{
[self.audioPlayer stop];
self.audioPlayer = [OWSSounds audioPlayerForSound:sound audioBehavior:OWSAudioBehavior_Playback];
self.audioPlayer = [SMKSound audioPlayerFor:sound audioBehavior:OWSAudioBehavior_Playback];
// Suppress looping in this view.
self.audioPlayer.isLooping = NO;
[self.audioPlayer play];
@ -153,10 +153,11 @@ NS_ASSUME_NONNULL_BEGIN
- (void)saveWasPressed:(id)sender
{
if (self.thread) {
[OWSSounds setNotificationSound:self.currentSound forThread:self.thread];
} else {
[OWSSounds setGlobalNotificationSound:self.currentSound];
if (self.threadId) {
[SMKSound setNotificationSound:self.currentSound forThreadId:self.threadId];
}
else {
[SMKSound setGlobalNotificationSound:self.currentSound];
}
[self.audioPlayer stop];

@ -214,9 +214,8 @@ final class ConversationCell : UITableViewCell {
// MARK: - Updating for search results
private func updateForSearchResult(_ threadViewModel: ThreadViewModel) {
AssertIsOnMainThread()
guard let thread = threadViewModel?.threadRecord else { return }
profilePictureView.update(for: thread)
GRDBStorage.shared.read { db in profilePictureView.update(db, thread: threadViewModel.thread) }
isPinnedIcon.isHidden = true
unreadCountView.isHidden = true
hasMentionView.isHidden = true

@ -37,6 +37,7 @@ public enum SNMessagingKit { // Just to make the external API nice
JobRunner.add(executor: MessageReceiveJob.self, for: .messageReceive)
JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer)
JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts)
JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload)
SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage)
}

@ -79,12 +79,17 @@ enum _001_InitialSetupMigration: Migration {
}
try db.create(table: ClosedGroupKeyPair.self) { t in
t.column(.publicKey, .text)
t.column(.threadId, .text)
.notNull()
.indexed() // Quicker querying
.references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted
t.column(.publicKey, .blob).notNull()
t.column(.secretKey, .blob).notNull()
t.column(.receivedTimestamp, .double).notNull()
t.column(.receivedTimestamp, .double)
.notNull()
.indexed() // Quicker querying
t.uniqueKey([.publicKey, .secretKey, .receivedTimestamp])
}
try db.create(table: OpenGroup.self) { t in
@ -217,6 +222,7 @@ enum _001_InitialSetupMigration: Migration {
t.column(.creationTimestamp, .double)
t.column(.sourceFilename, .text)
t.column(.downloadUrl, .text)
t.column(.localRelativeFilePath, .text)
t.column(.width, .integer)
t.column(.height, .integer)
t.column(.encryptionKey, .blob)
@ -291,6 +297,9 @@ enum _001_InitialSetupMigration: Migration {
t.column(.threadId, .text)
.indexed() // Quicker querying
.references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted
t.column(.interactionId, .text)
.indexed() // Quicker querying
.references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted
t.column(.details, .blob)
}
}

@ -18,10 +18,11 @@ enum _003_YDBToGRDBMigration: Migration {
var contacts: Set<Legacy.Contact> = []
var contactThreadIds: Set<String> = []
var legacyThreadIdToIdMap: [String: String] = [:]
var threads: Set<TSThread> = []
var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:]
var closedGroupKeys: [String: (timestamp: TimeInterval, keys: SessionUtilitiesKit.Legacy.KeyPair)] = [:]
var closedGroupKeys: [String: [TimeInterval: SessionUtilitiesKit.Legacy.KeyPair]] = [:]
var closedGroupName: [String: String] = [:]
var closedGroupFormation: [String: UInt64] = [:]
var closedGroupModel: [String: TSGroupModel] = [:]
@ -67,10 +68,11 @@ enum _003_YDBToGRDBMigration: Migration {
.asType(Legacy.DisappearingMessagesConfiguration.self)
.defaulting(to: Legacy.DisappearingMessagesConfiguration.defaultWith(threadId))
// Process the interactions
// Process group-specific info
guard let groupThread: TSGroupThread = thread as? TSGroupThread else { return }
guard let groupThread: TSGroupThread = thread as? TSGroupThread else {
legacyThreadIdToIdMap[threadId] = threadId.substring(from: Legacy.contactThreadPrefix.count)
return
}
if groupThread.isClosedGroup {
// The old threadId for closed groups was in the below format, we don't
@ -96,6 +98,7 @@ enum _003_YDBToGRDBMigration: Migration {
let keyCollection: String = "\(Legacy.closedGroupKeyPairPrefix)\(publicKey)"
legacyThreadIdToIdMap[threadId] = publicKey
closedGroupName[threadId] = groupThread.name(with: transaction)
closedGroupModel[threadId] = groupThread.groupModel
closedGroupFormation[threadId] = ((transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64) ?? 0)
@ -109,7 +112,8 @@ enum _003_YDBToGRDBMigration: Migration {
return
}
closedGroupKeys[threadId] = (timestamp, keyPair)
closedGroupKeys[threadId] = (closedGroupKeys[threadId] ?? [:])
.setting(timestamp, keyPair)
}
}
else if groupThread.isOpenGroup {
@ -119,6 +123,10 @@ enum _003_YDBToGRDBMigration: Migration {
return
}
legacyThreadIdToIdMap[threadId] = OpenGroup.idFor(
room: openGroup.room,
server: openGroup.server
)
openGroupInfo[threadId] = openGroup
openGroupUserCount[threadId] = ((transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupUserCountCollection) as? Int) ?? 0)
openGroupImage[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupImageCollection) as? Data
@ -163,101 +171,6 @@ enum _003_YDBToGRDBMigration: Migration {
.union(timestampsMs)
}
/*
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
owsFailDebug("Could not load view.")
return
}
guard let group = group else {
owsFailDebug("No group.")
return
}
// Deserializing interactions is expensive, so we only
// do that when necessary.
let sortIdForItemId: (String) -> UInt64? = { (itemId) in
guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else {
owsFailDebug("Could not load interaction.")
return nil
}
return interaction.sortId
}
self.viewName = TSMessageDatabaseViewExtensionName
self.group = group
// If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot.
// If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot.
var newItemIds = [ItemId]()
var canLoadMore = false
let desiredLength = self.desiredLength
// Not all items "count" towards the desired length. On an initial load, all items count. Subsequently,
// only items above the pivot count.
var afterPivotCount: UInt = 0
var beforePivotCount: UInt = 0
// (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block;
view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in
let itemId = key
// Load "uncounted" items after the pivot if possible.
//
// As an optimization, we can skip this check (which requires
// deserializing the interaction) if beforePivotCount is non-zero,
// e.g. after we "pass" the pivot.
if beforePivotCount == 0,
let pivotSortId = self.pivotSortId {
if let sortId = sortIdForItemId(itemId) {
let isAfterPivot = sortId > pivotSortId
if isAfterPivot {
newItemIds.append(itemId)
afterPivotCount += 1
return
}
} else {
owsFailDebug("Could not determine sort id for interaction: \(itemId)")
}
}
// Load "counted" items unless the load window overflows.
if beforePivotCount >= desiredLength {
// Overflow
canLoadMore = true
stop.pointee = true
} else {
newItemIds.append(itemId)
beforePivotCount += 1
}
}
NSMutableSet<NSString *> *interactionIds = [NSMutableSet new];
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
NSMutableArray<TSInteraction *> *interactions = [NSMutableArray new];
YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName];
OWSAssertDebug(viewTransaction);
for (NSString *uniqueId in loadedUniqueIds) {
TSInteraction *_Nullable interaction =
[TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction];
if (!interaction) {
OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId);
hasError = YES;
continue;
}
if (!interaction.uniqueId) {
OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction);
hasError = YES;
continue;
}
[interactions addObject:interaction];
if ([interactionIds containsObject:interaction.uniqueId]) {
OWSFailDebug(@"Duplicate interaction: %@", interaction.uniqueId);
continue;
}
[interactionIds addObject:interaction.uniqueId];
}
for (TSInteraction *interaction in interactions) {
tryToAddViewItem(interaction, transaction);
}
}];
*/
}
// We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here
@ -311,11 +224,19 @@ enum _003_YDBToGRDBMigration: Migration {
// MARK: - Insert Threads
print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start")
var legacyThreadIdToIdMap: [String: String] = [:]
var legacyInteractionToIdMap: [String: Int64] = [:]
var legacyInteractionIdentifierToIdMap: [String: Int64] = [:]
var legacyInteractionIdentifierToIdFallbackMap: [String: Int64] = [:]
var legacyAttachmentToIdMap: [String: String] = [:]
func identifier(for threadId: String, sentTimestamp: UInt64, recipients: [String], destination: Message.Destination? = nil) -> String {
func identifier(
for threadId: String,
sentTimestamp: UInt64,
recipients: [String],
destination: Message.Destination?,
variant: Interaction.Variant?,
useFallback: Bool
) -> String {
let recipientString: String = {
if let destination: Message.Destination = destination {
switch destination {
@ -328,41 +249,34 @@ enum _003_YDBToGRDBMigration: Migration {
}()
return [
"\(sentTimestamp)",
(useFallback ?
// Fallback to seconds-based accuracy (instead of milliseconds)
String("\(sentTimestamp)".prefix("\(Int(Date().timeIntervalSince1970))".count)) :
"\(sentTimestamp)"
),
(useFallback ? variant.map { "\($0)" } : nil),
recipientString,
threadId
]
.compactMap { $0 }
.joined(separator: "-")
}
try threads.forEach { thread in
guard let legacyThreadId: String = thread.uniqueId else { return }
guard
let legacyThreadId: String = thread.uniqueId,
let threadId: String = legacyThreadIdToIdMap[legacyThreadId]
else {
SNLog("[Migration Error] Unable to migrate thread with no id mapping")
throw GRDBStorageError.migrationFailed
}
let id: String
let variant: SessionThread.Variant
let threadVariant: SessionThread.Variant
let notificationMode: SessionThread.NotificationMode
switch thread {
case let groupThread as TSGroupThread:
if groupThread.isOpenGroup {
guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThreadId] else {
SNLog("[Migration Error] Open group missing required data")
throw GRDBStorageError.migrationFailed
}
id = openGroup.id
variant = .openGroup
}
else {
guard let publicKey: Data = closedGroupKeys[legacyThreadId]?.keys.publicKey else {
SNLog("[Migration Error] Closed group missing public key")
throw GRDBStorageError.migrationFailed
}
id = publicKey.toHexString()
variant = .closedGroup
}
threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup)
notificationMode = (thread.isMuted ? .none :
(groupThread.isOnlyNotifyingForMentions ?
.mentionsOnly :
@ -371,17 +285,14 @@ enum _003_YDBToGRDBMigration: Migration {
)
default:
id = legacyThreadId.substring(from: Legacy.contactThreadPrefix.count)
variant = .contact
threadVariant = .contact
notificationMode = (thread.isMuted ? .none : .all)
}
try autoreleasepool {
legacyThreadIdToIdMap[thread.uniqueId ?? ""] = id
try SessionThread(
id: id,
variant: variant,
id: threadId,
variant: threadVariant,
creationDateTimestamp: thread.creationDate.timeIntervalSince1970,
shouldBeVisible: thread.shouldBeVisible,
isPinned: thread.isPinned,
@ -391,9 +302,9 @@ enum _003_YDBToGRDBMigration: Migration {
).insert(db)
// Disappearing Messages Configuration
if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[id] {
if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] {
try DisappearingMessagesConfiguration(
threadId: id,
threadId: threadId,
isEnabled: config.isEnabled,
durationSeconds: TimeInterval(config.durationSeconds)
).insert(db)
@ -402,7 +313,7 @@ enum _003_YDBToGRDBMigration: Migration {
// Closed Groups
if (thread as? TSGroupThread)?.isClosedGroup == true {
guard
let keyInfo = closedGroupKeys[legacyThreadId],
let legacyKeys = closedGroupKeys[legacyThreadId],
let name: String = closedGroupName[legacyThreadId],
let groupModel: TSGroupModel = closedGroupModel[legacyThreadId],
let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId]
@ -412,20 +323,23 @@ enum _003_YDBToGRDBMigration: Migration {
}
try ClosedGroup(
threadId: id,
threadId: threadId,
name: name,
formationTimestamp: TimeInterval(formationTimestamp)
).insert(db)
try ClosedGroupKeyPair(
publicKey: keyInfo.keys.publicKey.toHexString(),
secretKey: keyInfo.keys.privateKey,
receivedTimestamp: keyInfo.timestamp
).insert(db)
try legacyKeys.forEach { timestamp, legacyKeys in
try ClosedGroupKeyPair(
threadId: threadId,
publicKey: legacyKeys.publicKey,
secretKey: legacyKeys.privateKey,
receivedTimestamp: timestamp
).insert(db)
}
try groupModel.groupMemberIds.forEach { memberId in
try GroupMember(
groupId: id,
groupId: threadId,
profileId: memberId,
role: .standard
).insert(db)
@ -433,7 +347,7 @@ enum _003_YDBToGRDBMigration: Migration {
try groupModel.groupAdminIds.forEach { adminId in
try GroupMember(
groupId: id,
groupId: threadId,
profileId: adminId,
role: .admin
).insert(db)
@ -441,7 +355,7 @@ enum _003_YDBToGRDBMigration: Migration {
try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in
try GroupMember(
groupId: id,
groupId: threadId,
profileId: zombieId,
role: .zombie
).insert(db)
@ -601,7 +515,7 @@ enum _003_YDBToGRDBMigration: Migration {
// Insert the data
let interaction: Interaction = try Interaction(
serverHash: serverHash,
threadId: id,
threadId: threadId,
authorId: authorId,
variant: variant,
body: body,
@ -624,12 +538,25 @@ enum _003_YDBToGRDBMigration: Migration {
// Store the interactionId in the lookup map to simplify job creation later
let legacyIdentifier: String = identifier(
for: legacyInteraction.uniqueThreadId,
for: threadId,
sentTimestamp: legacyInteraction.timestamp,
recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? [])
recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []),
destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil),
variant: variant,
useFallback: false
)
let legacyIdentifierFallback: String = identifier(
for: threadId,
sentTimestamp: legacyInteraction.timestamp,
recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []),
destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil),
variant: variant,
useFallback: true
)
legacyInteractionToIdMap[legacyInteraction.uniqueId ?? ""] = interactionId
legacyInteractionIdentifierToIdMap[legacyIdentifier] = interactionId
legacyInteractionIdentifierToIdFallbackMap[legacyIdentifierFallback] = interactionId
// Handle the recipient states
@ -678,12 +605,24 @@ enum _003_YDBToGRDBMigration: Migration {
throw GRDBStorageError.migrationFailed
}
// Setup the attachment and add it to the lookup (if it exists)
let attachmentId: String? = try attachmentId(
db,
for: quoteAttachmentId,
attachments: attachments
)
if let quoteAttachmentId: String = quoteAttachmentId, let attachmentId: String = attachmentId {
legacyAttachmentToIdMap[quoteAttachmentId] = attachmentId
}
// Create the quote
try Quote(
interactionId: interactionId,
authorId: quotedMessage.authorId,
timestampMs: Int64(quotedMessage.timestamp),
body: quotedMessage.body,
attachmentId: try attachmentId(db, for: quoteAttachmentId, attachments: attachments)
attachmentId: attachmentId
).insert(db)
}
@ -699,6 +638,17 @@ enum _003_YDBToGRDBMigration: Migration {
throw GRDBStorageError.migrationFailed
}
// Setup the attachment and add it to the lookup (if it exists)
let attachmentId: String? = try attachmentId(
db,
for: linkPreview.imageAttachmentId,
attachments: attachments
)
if let legacyAttachmentId: String = linkPreview.imageAttachmentId, let attachmentId: String = attachmentId {
legacyAttachmentToIdMap[legacyAttachmentId] = attachmentId
}
// Note: It's possible for there to be duplicate values here so we use 'save'
// instead of insert (ie. upsert)
try LinkPreview(
@ -706,13 +656,12 @@ enum _003_YDBToGRDBMigration: Migration {
timestamp: timestamp,
variant: linkPreviewVariant,
title: linkPreview.title,
attachmentId: try attachmentId(db, for: linkPreview.imageAttachmentId, attachments: attachments)
attachmentId: attachmentId
).save(db)
}
// Handle any attachments
print("ASD \(attachmentIds)")
try attachmentIds.forEach { legacyAttachmentId in
guard let attachmentId: String = try attachmentId(db, for: legacyAttachmentId, interactionVariant: variant, attachments: attachments) else {
// TODO: Is it possible to hit this case if an interaction hasn't been viewed?
@ -720,10 +669,13 @@ enum _003_YDBToGRDBMigration: Migration {
throw GRDBStorageError.migrationFailed
}
// Link the attachment to the interaction and add to the id lookup
try InteractionAttachment(
interactionId: interactionId,
attachmentId: attachmentId
).insert(db)
legacyAttachmentToIdMap[legacyAttachmentId] = attachmentId
}
}
}
@ -760,6 +712,31 @@ enum _003_YDBToGRDBMigration: Migration {
var attachmentUploadJobs: Set<Legacy.AttachmentUploadJob> = []
var attachmentDownloadJobs: Set<Legacy.AttachmentDownloadJob> = []
// Map the Legacy types for the NSKeyedUnarchiver
NSKeyedUnarchiver.setClass(
Legacy.NotifyPNServerJob.self,
forClassName: "SessionMessagingKit.NotifyPNServerJob"
)
NSKeyedUnarchiver.setClass(
Legacy.NotifyPNServerJob.SnodeMessage.self,
forClassName: "SessionSnodeKit.SnodeMessage"
)
NSKeyedUnarchiver.setClass(
Legacy.MessageSendJob.self,
forClassName: "SessionMessagingKit.SNMessageSendJob"
)
NSKeyedUnarchiver.setClass(
Legacy.MessageReceiveJob.self,
forClassName: "SessionMessagingKit.MessageReceiveJob"
)
NSKeyedUnarchiver.setClass(
Legacy.AttachmentUploadJob.self,
forClassName: "SessionMessagingKit.AttachmentUploadJob"
)
NSKeyedUnarchiver.setClass(
Legacy.AttachmentDownloadJob.self,
forClassName: "SessionMessagingKit.AttachmentDownloadJob"
)
Storage.read { transaction in
transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in
guard let job = object as? Legacy.NotifyPNServerJob else { return }
@ -798,16 +775,15 @@ enum _003_YDBToGRDBMigration: Migration {
variant: .notifyPushServer,
behaviour: .runOnce,
nextRunTimestamp: 0,
details: String(
data: try JSONEncoder().encode(
SnodeMessage(
recipient: legacyJob.message.recipient,
data: legacyJob.message.data.description, // TODO: Test this (looks like it should be fine)
ttl: legacyJob.message.ttl,
timestampMs: legacyJob.message.timestamp
)
),
encoding: .utf8
details: NotifyPushServerJob.Details(
message: SnodeMessage(
recipient: legacyJob.message.recipient,
// Note: The legacy type had 'LosslessStringConvertible' so we need
// to use '.description' to get it as a basic string
data: legacyJob.message.data.description,
ttl: legacyJob.message.ttl,
timestampMs: legacyJob.message.timestamp
)
)
)?.inserted(db)
}
@ -823,20 +799,24 @@ enum _003_YDBToGRDBMigration: Migration {
return
}
// We need to extract the `threadId` from the legacyJob data as the new
// MessageReceiveJob requires it for multi-threading and garbage collection purposes
guard let envelope: SNProtoEnvelope = try? SNProtoEnvelope.parseData(legacyJob.data) else {
return
}
let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope)
_ = try Job(
failureCount: legacyJob.failureCount,
variant: .messageReceive,
behaviour: .runOnce,
nextRunTimestamp: 0,
details: String(
data: try JSONEncoder().encode(
MessageReceiveJob.Details(
data: legacyJob.data,
serverHash: legacyJob.serverHash,
isBackgroundPoll: legacyJob.isBackgroundPoll
)
),
encoding: .utf8
threadId: threadId,
details: MessageReceiveJob.Details(
data: legacyJob.data,
serverHash: legacyJob.serverHash,
isBackgroundPoll: legacyJob.isBackgroundPoll
)
)?.inserted(db)
}
@ -848,26 +828,68 @@ enum _003_YDBToGRDBMigration: Migration {
try autoreleasepool {
try messageSendJobs.forEach { legacyJob in
let legacyIdentifier: String = identifier(
for: (legacyJob.message.threadID ?? ""),
sentTimestamp: (legacyJob.message.sentTimestamp ?? 0),
recipients: (legacyJob.message.recipient.map { [$0] } ?? []),
destination: legacyJob.destination
)
// Fetch the interaction this job should be associated with
// Fetch the threadId and interactionId this job should be associated with
let threadId: String = {
switch legacyJob.destination {
case .contact(let publicKey): return publicKey
case .closedGroup(let groupPublicKey): return groupPublicKey
case .openGroupV2(let room, let server):
return OpenGroup.idFor(room: room, server: server)
case .openGroup: return ""
}
}()
let interactionId: Int64? = {
// The 'Legacy.Job' 'id' value was "(timestamp)(num jobs for this timestamp)"
// so we can reverse-engineer an approximate timestamp by extracting it from
// the id (this value is unlikely to match exactly though)
let fallbackTimestamp: UInt64 = legacyJob.id
.map { UInt64($0.prefix("\(Int(Date().timeIntervalSince1970 * 1000))".count)) }
.defaulting(to: 0)
let legacyIdentifier: String = identifier(
for: threadId,
sentTimestamp: (legacyJob.message.sentTimestamp ?? fallbackTimestamp),
recipients: (legacyJob.message.recipient.map { [$0] } ?? []),
destination: legacyJob.destination,
variant: nil,
useFallback: false
)
if let matchingId: Int64 = legacyInteractionIdentifierToIdMap[legacyIdentifier] {
return matchingId
}
// If we didn't find the correct interaction then we need to try the "fallback"
// identifier which is less accurate (during testing this only happened for
// 'ExpirationTimerUpdate' send jobs)
let fallbackIdentifier: String = identifier(
for: threadId,
sentTimestamp: (legacyJob.message.sentTimestamp ?? fallbackTimestamp),
recipients: (legacyJob.message.recipient.map { [$0] } ?? []),
destination: legacyJob.destination,
variant: {
switch legacyJob.message {
case is ExpirationTimerUpdate: return .infoDisappearingMessagesUpdate
default: return nil
}
}(),
useFallback: true
)
return legacyInteractionIdentifierToIdFallbackMap[fallbackIdentifier]
}()
let job: Job? = try Job(
failureCount: legacyJob.failureCount,
variant: .messageSend,
behaviour: .runOnce,
nextRunTimestamp: 0,
threadId: legacyThreadIdToIdMap[legacyJob.message.threadID ?? ""],
threadId: threadId,
details: MessageSendJob.Details(
// Note: There are some cases where there isn't actually a link between the 'MessageSendJob' and
// it's associated interaction (ie. any ControlMessage), in these cases the 'interactionId' value
// will be nil
interactionId: legacyInteractionIdentifierToIdMap[legacyIdentifier],
// Note: There are some cases where there isn't a link between a
// 'MessageSendJob' and an interaction (eg. ConfigurationMessage),
// in these cases the 'interactionId' value will be nil
interactionId: interactionId,
destination: legacyJob.destination,
message: legacyJob.message
)
@ -893,15 +915,10 @@ enum _003_YDBToGRDBMigration: Migration {
variant: .attachmentUpload,
behaviour: .runOnce,
nextRunTimestamp: 0,
details: String(
data: try JSONEncoder().encode(
AttachmentUploadJob.Details(
threadId: legacyJob.threadID,
attachmentId: legacyJob.attachmentID,
messageSendJobId: sendJobId
)
),
encoding: .utf8
details: AttachmentUploadJob.Details(
threadId: legacyJob.threadID,
attachmentId: legacyJob.attachmentID,
messageSendJobId: sendJobId
)
)?.inserted(db)
}
@ -915,20 +932,20 @@ enum _003_YDBToGRDBMigration: Migration {
SNLog("[Migration Error] attachmentDownload job unable to find interaction")
throw GRDBStorageError.migrationFailed
}
guard let attachmentId: String = legacyAttachmentToIdMap[legacyJob.attachmentID] else {
SNLog("[Migration Error] attachmentDownload job unable to find attachment")
throw GRDBStorageError.migrationFailed
}
_ = try Job(
failureCount: legacyJob.failureCount,
variant: .attachmentDownload,
behaviour: .runOnce,
nextRunTimestamp: 0,
details: String(
data: try JSONEncoder().encode(
AttachmentDownloadJob.Details(
threadId: legacyJob.threadID,
attachmentId: legacyJob.attachmentID
)
),
encoding: .utf8
threadId: legacyThreadIdToIdMap[legacyJob.threadID],
interactionId: interactionId,
details: AttachmentDownloadJob.Details(
attachmentId: attachmentId
)
)?.inserted(db)
}

@ -21,6 +21,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec
case creationTimestamp
case sourceFilename
case downloadUrl
case localRelativeFilePath
case width
case height
case encryptionKey
@ -81,6 +82,11 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec
/// **Note:** The url is a fully constructed url but the clients just extract the id from the end of the url to perform the actual download
public let downloadUrl: String?
/// The file path for the attachment relative to the attachments folder
///
/// **Note:** We store this path so that file path generation changes dont break existing attachments
public let localRelativeFilePath: String?
/// The width of the attachment, this will be `null` for non-visual attachment types
public let width: UInt?
@ -107,6 +113,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec
creationTimestamp: TimeInterval? = nil,
sourceFilename: String? = nil,
downloadUrl: String? = nil,
localRelativeFilePath: String? = nil,
width: UInt? = nil,
height: UInt? = nil,
encryptionKey: Data? = nil,
@ -121,6 +128,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec
self.creationTimestamp = creationTimestamp
self.sourceFilename = sourceFilename
self.downloadUrl = downloadUrl
self.localRelativeFilePath = localRelativeFilePath
self.width = width
self.height = height
self.encryptionKey = encryptionKey
@ -153,6 +161,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec
self.creationTimestamp = nil
self.sourceFilename = nil
self.downloadUrl = nil
self.localRelativeFilePath = nil
self.width = imageSize.map { UInt(floor($0.width)) }
self.height = imageSize.map { UInt(floor($0.height)) }
self.encryptionKey = nil
@ -164,16 +173,41 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec
// MARK: - CustomStringConvertible
extension Attachment: CustomStringConvertible {
public var description: String {
public static func description(for variant: Variant, contentType: String, sourceFilename: String?) -> String {
if MIMETypeUtil.isAudio(contentType) {
// a missing filename is the legacy way to determine if an audio attachment is
// a voice note vs. other arbitrary audio attachments.
if variant == .voiceMessage || self.sourceFilename == nil || (self.sourceFilename?.count ?? 0) == 0 {
if variant == .voiceMessage || sourceFilename == nil || (sourceFilename?.count ?? 0) == 0 {
return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())"
}
}
return "\("ATTACHMENT".localized()) \(emojiForMimeType)"
return "\("ATTACHMENT".localized()) \(emoji(for: contentType))"
}
public static func emoji(for contentType: String) -> String {
if MIMETypeUtil.isImage(contentType) {
return "📷"
}
else if MIMETypeUtil.isVideo(contentType) {
return "🎥"
}
else if MIMETypeUtil.isAudio(contentType) {
return "🎧"
}
else if MIMETypeUtil.isAnimated(contentType) {
return "🎡"
}
return "📎"
}
public var description: String {
return Attachment.description(
for: variant,
contentType: contentType,
sourceFilename: sourceFilename
)
}
}
@ -183,7 +217,9 @@ public extension Attachment {
func with(
serverId: String? = nil,
state: State? = nil,
creationTimestamp: TimeInterval? = nil,
downloadUrl: String? = nil,
localRelativeFilePath: String? = nil,
encryptionKey: Data? = nil,
digest: Data? = nil
) -> Attachment {
@ -193,9 +229,10 @@ public extension Attachment {
state: (state ?? self.state),
contentType: contentType,
byteCount: byteCount,
creationTimestamp: creationTimestamp,
creationTimestamp: (creationTimestamp ?? self.creationTimestamp),
sourceFilename: sourceFilename,
downloadUrl: (downloadUrl ?? self.downloadUrl),
localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath),
width: width,
height: height,
encryptionKey: (encryptionKey ?? self.encryptionKey),
@ -236,6 +273,7 @@ public extension Attachment {
self.creationTimestamp = nil
self.sourceFilename = proto.fileName
self.downloadUrl = proto.url
self.localRelativeFilePath = nil
self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil)
self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil)
self.encryptionKey = proto.key
@ -343,7 +381,7 @@ public extension Attachment {
OWSFileSystem.appSharedDataDirectoryPath().appending("/Attachments")
}()
private static var attachmentsFolder: String = {
internal static var attachmentsFolder: String = {
let attachmentsFolder: String = sharedDataAttachmentsDirPath
OWSFileSystem.ensureDirectoryExists(attachmentsFolder)
@ -357,22 +395,13 @@ public extension Attachment {
return attachmentsFolder
}()
private static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? {
let maybeFilePath: String? = MIMETypeUtil.filePath(
forAttachment: id, // TODO: Can we avoid this???
internal static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? {
return MIMETypeUtil.filePath(
forAttachment: id,
ofMIMEType: mimeType,
sourceFilename: sourceFilename,
inFolder: Attachment.attachmentsFolder
)
guard let filePath: String = maybeFilePath else { return nil }
guard filePath.hasPrefix(Attachment.attachmentsFolder) else { return nil }
let localRelativeFilePath: String = filePath.substring(from: Attachment.attachmentsFolder.count)
guard !localRelativeFilePath.isEmpty else { return nil }
return localRelativeFilePath
}
static func imageSize(contentType: String, originalFilePath: String) -> CGSize? {
@ -410,10 +439,6 @@ extension Attachment {
)
}
var localRelativeFilePath: String? {
return originalFilePath?.substring(from: Attachment.attachmentsFolder.count)
}
var thumbnailsDirPath: String {
// Thumbnails are written to the caches directory, so that iOS can
// remove them if necessary
@ -435,23 +460,6 @@ extension Attachment {
return UIImage(contentsOfFile: originalFilePath)
}
var emojiForMimeType: String {
if MIMETypeUtil.isImage(contentType) {
return "📷"
}
else if MIMETypeUtil.isVideo(contentType) {
return "🎥"
}
else if MIMETypeUtil.isAudio(contentType) {
return "🎧"
}
else if MIMETypeUtil.isAnimated(contentType) {
return "🎡"
}
return "📎"
}
var isImage: Bool { MIMETypeUtil.isImage(contentType) }
var isVideo: Bool { MIMETypeUtil.isVideo(contentType) }
var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) }
@ -530,4 +538,12 @@ extension Attachment {
public func cloneAsThumbnail() -> Attachment {
fatalError("TODO: Add this back")
}
public func write(data: Data) throws -> Bool {
guard let originalFilePath: String = originalFilePath else { return false }
try data.write(to: URL(fileURLWithPath: originalFilePath))
return true
}
}

@ -4,22 +4,24 @@ import Foundation
import GRDB
import SessionUtilitiesKit
public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "closedGroupKeyPair" }
internal static let closedGroupForeignKey = ForeignKey(
[Columns.publicKey],
[Columns.threadId],
to: [ClosedGroup.Columns.threadId]
)
private static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case threadId
case publicKey
case secretKey
case receivedTimestamp
}
public let publicKey: String
public let threadId: String
public let publicKey: Data
public let secretKey: Data
public let receivedTimestamp: TimeInterval
@ -32,10 +34,12 @@ public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, T
// MARK: - Initialization
public init(
publicKey: String,
threadId: String,
publicKey: Data,
secretKey: Data,
receivedTimestamp: TimeInterval
) {
self.threadId = threadId
self.publicKey = publicKey
self.secretKey = secretKey
self.receivedTimestamp = receivedTimestamp
@ -45,9 +49,9 @@ public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, T
// MARK: - GRDB Interactions
public extension ClosedGroupKeyPair {
static func fetchLatestKeyPair(_ db: Database, publicKey: String) throws -> ClosedGroupKeyPair? {
static func fetchLatestKeyPair(_ db: Database, threadId: String) throws -> ClosedGroupKeyPair? {
return try ClosedGroupKeyPair
.filter(Columns.publicKey == publicKey)
.filter(Columns.threadId == threadId)
.order(Columns.receivedTimestamp.desc)
.fetchOne(db)
}

@ -309,9 +309,6 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
default: break
}
}
}
public func delete(_ db: Database) throws -> Bool {
@ -394,7 +391,7 @@ public extension Interaction {
func scheduleJobs(interactionIds: [Int64]) {
// Add the 'DisappearingMessagesJob' if needed - this will update any expiring
// messages `expiresStartedAtMs` values
JobRunner.add(
JobRunner.upsert(
db,
job: Job(
variant: .disappearingMessages,
@ -541,6 +538,13 @@ public extension Interaction {
case .standardIncomingDeleted: return ""
case .standardIncoming, .standardOutgoing:
struct AttachmentDescriptionInfo: Decodable, FetchableRecord {
let id: String
let variant: Attachment.Variant
let contentType: String
let sourceFilename: String?
}
var bodyDescription: String?
if let body: String = self.body, !body.isEmpty {
@ -548,14 +552,35 @@ public extension Interaction {
}
if bodyDescription == nil {
let maybeTextAttachment: Attachment? = try? attachments
.filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage)
.fetchOne(db)
struct AttachmentBodyInfo: Decodable, FetchableRecord {
let id: String
let variant: Attachment.Variant
let contentType: String
let sourceFilename: String?
}
let maybeTextInfo: AttachmentDescriptionInfo? = try? AttachmentDescriptionInfo
.fetchOne(
db,
attachments
.select(
Attachment.Columns.id,
Attachment.Columns.state,
Attachment.Columns.variant,
Attachment.Columns.contentType,
Attachment.Columns.sourceFilename
)
.filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage)
.filter(Attachment.Columns.state == Attachment.State.downloaded)
)
if
let attachment: Attachment = maybeTextAttachment,
attachment.state == .downloaded,
let filePath: String = attachment.originalFilePath,
let textInfo: AttachmentDescriptionInfo = maybeTextInfo,
let filePath: String = Attachment.originalFilePath(
id: textInfo.id,
mimeType: textInfo.contentType,
sourceFilename: textInfo.sourceFilename
),
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)),
let dataString: String = String(data: data, encoding: .utf8)
{
@ -563,14 +588,25 @@ public extension Interaction {
}
}
var attachmentDescription: String?
let maybeMediaAttachment: Attachment? = try? attachments
.filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage)
.fetchOne(db)
if let attachment: Attachment = maybeMediaAttachment {
attachmentDescription = attachment.description
}
let attachmentDescription: String? = try? AttachmentDescriptionInfo
.fetchOne(
db,
attachments
.select(
Attachment.Columns.id,
Attachment.Columns.variant,
Attachment.Columns.contentType,
Attachment.Columns.sourceFilename
)
.filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage)
)
.map { info -> String in
Attachment.description(
for: info.variant,
contentType: info.contentType,
sourceFilename: info.sourceFilename
)
}
if
let attachmentDescription: String = attachmentDescription,
@ -627,9 +663,12 @@ public extension Interaction {
}
func state(_ db: Database) throws -> RecipientState.State {
let states: [RecipientState.State] = try recipientStates
.fetchAll(db)
.map { $0.state }
let states: [RecipientState.State] = try RecipientState.State
.fetchAll(
db,
recipientStates
.select(RecipientState.Columns.state)
)
var hasFailed: Bool = false
for state in states {

@ -36,9 +36,11 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord
// If we have an Attachment then check if this is the only type that is referencing it
// and delete the Attachment if so
let quoteUses: Int? = try? Quote
.select(Quote.Columns.attachmentId)
.filter(Quote.Columns.attachmentId == attachmentId)
.fetchCount(db)
let linkPreviewUses: Int? = try? LinkPreview
.select(LinkPreview.Columns.attachmentId)
.filter(LinkPreview.Columns.attachmentId == attachmentId)
.fetchCount(db)

@ -9,9 +9,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
public static var databaseTableName: String { "job" }
internal static let threadForeignKey = ForeignKey(
[Columns.threadId],
to: [Interaction.Columns.threadId]
to: [SessionThread.Columns.id]
)
internal static let interactionForeignKey = ForeignKey(
[Columns.interactionId],
to: [Interaction.Columns.id]
)
internal static let thread = hasOne(SessionThread.self, using: Job.threadForeignKey)
internal static let interaction = hasOne(Interaction.self, using: Job.interactionForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
@ -21,6 +26,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
case behaviour
case nextRunTimestamp
case threadId
case interactionId
case details
}
@ -101,11 +107,18 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
/// Seconds since epoch to indicate the next datetime that this job should run
public let nextRunTimestamp: TimeInterval
/// The id of the thread this job is associated with
/// The id of the thread this job is associated with, if the associated thread is deleted this job will
/// also be deleted
///
/// **Note:** This will only be populated for Jobs associated to threads
public let threadId: String?
/// The id of the interaction this job is associated with, if the associated interaction is deleted this
/// job will also be deleted
///
/// **Note:** This will only be populated for Jobs associated to interactions
public let interactionId: Int64?
/// JSON encoded data required for the job
public let details: Data?
@ -115,6 +128,10 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
request(for: Job.thread)
}
public var interaction: QueryInterfaceRequest<Interaction> {
request(for: Job.interaction)
}
// MARK: - Initialization
fileprivate init(
@ -124,6 +141,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
behaviour: Behaviour,
nextRunTimestamp: TimeInterval,
threadId: String?,
interactionId: Int64?,
details: Data?
) {
self.id = id
@ -132,6 +150,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
self.behaviour = behaviour
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.interactionId = interactionId
self.details = details
}
@ -140,13 +159,15 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
variant: Variant,
behaviour: Behaviour = .runOnce,
nextRunTimestamp: TimeInterval = 0,
threadId: String? = nil
threadId: String? = nil,
interactionId: Int64? = nil
) {
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.interactionId = interactionId
self.details = nil
}
@ -156,24 +177,20 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
behaviour: Behaviour = .runOnce,
nextRunTimestamp: TimeInterval = 0,
threadId: String? = nil,
details: T? = nil
interactionId: Int64? = nil,
details: T?
) {
let detailsData: Data?
if let details: T = details {
guard let encodedDetails: Data = try? JSONEncoder().encode(details) else { return nil }
detailsData = encodedDetails
}
else {
detailsData = nil
}
guard
let details: T = details,
let detailsData: Data = try? JSONEncoder().encode(details)
else { return nil }
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.interactionId = interactionId
self.details = detailsData
}
@ -198,6 +215,7 @@ public extension Job {
behaviour: behaviour,
nextRunTimestamp: (nextRunTimestamp ?? self.nextRunTimestamp),
threadId: threadId,
interactionId: interactionId,
details: details
)
}
@ -212,6 +230,7 @@ public extension Job {
behaviour: behaviour,
nextRunTimestamp: nextRunTimestamp,
threadId: threadId,
interactionId: interactionId,
details: detailsData
)
}

@ -93,7 +93,6 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco
userCount: Int,
infoUpdates: Int
) {
// Always force the server to lowercase
self.threadId = OpenGroup.idFor(room: room, server: server)
self.server = server.lowercased()
self.room = room

@ -40,14 +40,38 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
case mentionsOnly // Only applicable to group threads
}
/// Unique identifier for a thread (formerly known as uniqueId)
///
/// This value will depend on the variant:
/// **contact:** The contact id
/// **closedGroup:** The closed group public key
/// **openGroup:** The `\(server.lowercased()).\(room)` value
public let id: String
/// Enum indicating what type of thread this is
public let variant: Variant
/// A timestamp indicating when this thread was created
public let creationDateTimestamp: TimeInterval
/// A flag indicating whether the thread should be visible
public let shouldBeVisible: Bool
/// A flag indicating whether the thread is pinned
public let isPinned: Bool
/// The value the user started entering into the input field before they left the conversation screen
public let messageDraft: String?
/// The notification mode this thread is set to
public let notificationMode: NotificationMode
/// The sound which should be used when receiving a notification for this thread
///
/// **Note:** If unset this will use the `Preferences.Sound.defaultNotificationSound`
public let notificationSound: Preferences.Sound?
/// Timestamp (seconds since epoch) for when this thread should stop being muted
public let mutedUntilTimestamp: TimeInterval?
// MARK: - Relationships
@ -142,14 +166,14 @@ public extension SessionThread {
case .contact: return Profile.displayName(db, id: id)
case .closedGroup:
guard let name: String = try? closedGroup.fetchOne(db)?.name, !name.isEmpty else {
guard let name: String = try? String.fetchOne(db, closedGroup.select(ClosedGroup.Columns.name)), !name.isEmpty else {
return "Group"
}
return name
case .openGroup:
guard let name: String = try? openGroup.fetchOne(db)?.name, !name.isEmpty else {
guard let name: String = try? String.fetchOne(db, openGroup.select(OpenGroup.Columns.name)), !name.isEmpty else {
return "Group"
}

@ -1,166 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SessionUtilitiesKit
import SessionSnodeKit
import SignalCoreKit
public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public let attachmentID: String
public let tsMessageID: String
public let threadID: String
public var delegate: JobDelegate?
public var id: String?
public var failureCount: UInt = 0
public var isDeferred = false
public enum Error : LocalizedError {
case noAttachment
case invalidURL
public var errorDescription: String? {
switch self {
case .noAttachment: return "No such attachment."
case .invalidURL: return "Invalid file URL."
}
}
}
// MARK: Settings
public class var collection: String { return "AttachmentDownloadJobCollection" }
public static let maxFailureCount: UInt = 20
// MARK: Initialization
public init(attachmentID: String, tsMessageID: String, threadID: String) {
self.attachmentID = attachmentID
self.tsMessageID = tsMessageID
self.threadID = threadID
}
// MARK: Coding
public init?(coder: NSCoder) {
guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?,
let threadID = coder.decodeObject(forKey: "threadID") as! String?,
let id = coder.decodeObject(forKey: "id") as! String? else { return nil }
self.attachmentID = attachmentID
self.tsMessageID = tsMessageID
self.threadID = threadID
self.id = id
self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
self.isDeferred = coder.decodeBool(forKey: "isDeferred")
}
public func encode(with coder: NSCoder) {
coder.encode(attachmentID, forKey: "attachmentID")
coder.encode(tsMessageID, forKey: "tsIncomingMessageID")
coder.encode(threadID, forKey: "threadID")
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
coder.encode(isDeferred, forKey: "isDeferred")
}
// MARK: Running
public func execute() {
if let id = id {
JobQueue.currentlyExecutingJobs.insert(id)
}
guard !isDeferred else { return }
if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream {
// FIXME: It's not clear * how * this happens, but apparently we can get to this point
// from time to time with an already downloaded attachment.
return handleSuccess()
}
guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else {
return handleFailure(error: Error.noAttachment)
}
let storage = SNMessagingKitConfiguration.shared.storage
storage.write(with: { transaction in
storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction)
}, completion: { })
let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString)
let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self
OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
if let error = error as? Error, case .noAttachment = error {
storage.write(with: { transaction in
storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction)
}, completion: { })
self.handlePermanentFailure(error: error)
} else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error,
statusCode == 400 {
// Otherwise, the attachment will show a state of downloading forever,
// and the message won't be able to be marked as read.
storage.write(with: { transaction in
storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction)
}, completion: { })
// This usually indicates a file that has expired on the server, so there's no need to retry.
self.handlePermanentFailure(error: error)
} else {
self.handleFailure(error: error)
}
}
if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) {
guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else {
return handleFailure(Error.invalidURL)
}
OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in
self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure)
}.catch(on: DispatchQueue.global()) { error in
handleFailure(error)
}
} else {
guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else {
return handleFailure(Error.invalidURL)
}
let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer)
FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in
self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure)
}.catch(on: DispatchQueue.global()) { error in
handleFailure(error)
}
}
}
private func handleDownloadedAttachment(data: Data, temporaryFilePath: URL, pointer: TSAttachmentPointer, failureHandler: (Swift.Error) -> Void) {
let storage = SNMessagingKitConfiguration.shared.storage
do {
try data.write(to: temporaryFilePath, options: .atomic)
} catch {
return failureHandler(error)
}
let plaintext: Data
if let key = pointer.encryptionKey, let digest = pointer.digest, key.count > 0 && digest.count > 0 {
do {
plaintext = try Cryptography.decryptAttachment(data, withKey: key, digest: digest, unpaddedSize: pointer.byteCount)
} catch {
return failureHandler(error)
}
} else {
plaintext = data // Open group attachments are unencrypted
}
let stream = TSAttachmentStream(pointer: pointer)
do {
try stream.write(plaintext)
} catch {
return failureHandler(error)
}
OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
storage.write(with: { transaction in
storage.persist(stream, associatedWith: self.tsMessageID, using: transaction)
}, completion: {
self.handleSuccess()
})
}
private func handleSuccess() {
delegate?.handleJobSucceeded(self)
}
private func handlePermanentFailure(error: Swift.Error) {
delegate?.handleJobFailedPermanently(self, with: error)
}
private func handleFailure(error: Swift.Error) {
delegate?.handleJobFailed(self, with: error)
}
}

@ -8,6 +8,7 @@ import SessionUtilitiesKit
public protocol JobExecutor {
static var maxFailureCount: UInt { get }
static var requiresThreadId: Bool { get }
static var requiresInteractionId: Bool { get }
/// This method contains the logic needed to complete a job
///
@ -35,10 +36,11 @@ public final class JobRunner {
private class Trigger {
private var timer: Timer?
static func create(timestamp: TimeInterval) -> Trigger {
static func create(timestamp: TimeInterval) -> Trigger? {
// Setup the trigger (wait at least 1 second before triggering)
let trigger: Trigger = Trigger()
trigger.timer = Timer.scheduledTimer(
timeInterval: timestamp,
timeInterval: max(1, (timestamp - Date().timeIntervalSince1970)),
target: self,
selector: #selector(start),
userInfo: nil,
@ -57,7 +59,6 @@ public final class JobRunner {
// TODO: Could this be a bottleneck? (single serial queue to process all these jobs? Group by thread?)
// TODO: Multi-thread support
private static let minRetryInterval: TimeInterval = 1
private static let queueKey: DispatchSpecificKey = DispatchSpecificKey<String>()
private static let queueContext: String = "JobRunner"
private static let internalQueue: DispatchQueue = {
@ -82,6 +83,11 @@ public final class JobRunner {
// MARK: - Execution
/// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start
/// the JobRunner
///
/// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp`
/// is in the future then the job won't be started
public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) {
// Store the job into the database (getting an id for it)
guard let updatedJob: Job = try? job?.inserted(db) else {
@ -89,10 +95,12 @@ public final class JobRunner {
return
}
switch (canStartJob, updatedJob.behaviour) {
case (false, _), (_, .runOnceNextLaunch): return
default: break
}
// Check if the job should be added to the queue
guard
canStartJob,
updatedJob.behaviour != .runOnceNextLaunch,
updatedJob.nextRunTimestamp <= Date().timeIntervalSince1970
else { return }
jobQueue.mutate { $0.append(updatedJob) }
@ -104,6 +112,11 @@ public final class JobRunner {
}
}
/// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start
/// the JobRunner
///
/// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp`
/// is in the future then the job won't be started
public static func upsert(_ db: Database, job: Job?, canStartJob: Bool = true) {
guard let job: Job = job else { return } // Ignore null jobs
guard let jobId: Int64 = job.id else {
@ -113,6 +126,9 @@ public final class JobRunner {
// Lock the queue while checking the index and inserting to ensure we don't run into
// any multi-threading shenanigans
//
// Note: currently running jobs are removed from the queue so we don't need to check
// the 'jobsCurrentlyRunning' set
var didUpdateExistingJob: Bool = false
jobQueue.mutate { queue in
@ -230,15 +246,28 @@ public final class JobRunner {
.fetchAll(db)
}
// Determine the number of jobs to run
var jobCount: Int = 0
jobQueue.mutate { queue in
// Add the jobs to the queue
if let jobsToRun: [Job] = maybeJobsToRun {
queue.append(contentsOf: jobsToRun)
}
jobCount = queue.count
}
// If there are no pending jobs then schedule the JobRunner to start again
// when the next scheduled job should start
guard let jobsToRun: [Job] = maybeJobsToRun else {
guard jobCount > 0 else {
isRunning.mutate { $0 = false }
scheduleNextSoonestJob()
return
}
// Add the jobs to the queue and run the first job in the queue
jobQueue.mutate { $0.append(contentsOf: jobsToRun) }
// Run the first job in the queue
SNLog("[JobRunner] Starting with (\(jobCount) job\(jobCount != 1 ? "s" : ""))")
runNextJob()
}
@ -250,9 +279,9 @@ public final class JobRunner {
}
return
}
guard let nextJob: Job = jobQueue.mutate({ $0.popFirst() }) else {
scheduleNextSoonestJob()
guard let (nextJob, numJobsRemaining): (Job, Int) = jobQueue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else {
isRunning.mutate { $0 = false }
scheduleNextSoonestJob()
return
}
guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else {
@ -265,13 +294,17 @@ public final class JobRunner {
handleJobFailed(nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true)
return
}
guard !jobExecutor.requiresInteractionId || nextJob.interactionId != nil else {
SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing required interactionId")
handleJobFailed(nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true)
return
}
// Update the state to indicate it's running
//
// Note: We need to store 'numJobsRemaining' in it's own variable because
// the 'SNLog' seems to dispatch to it's own queue which ends up getting
// blocked by the JobRunner's queue becuase 'jobQueue' is Atomic
let numJobsRemaining: Int = jobQueue.wrappedValue.count
nextTrigger.mutate { $0 = nil }
isRunning.mutate { $0 = true }
jobsCurrentlyRunning.mutate { $0 = $0.inserting(nextJob.id) }
@ -286,19 +319,38 @@ public final class JobRunner {
}
private static func scheduleNextSoonestJob() {
let maybeJob: Job? = GRDBStorage.shared.read { db in
try Job
.filter(
[
Job.Behaviour.runOnce,
Job.Behaviour.recurring
].contains(Job.Columns.behaviour)
)
.order(Job.Columns.nextRunTimestamp)
.fetchOne(db)
let nextJobTimestamp: TimeInterval? = GRDBStorage.shared
.read { db in
try TimeInterval
.fetchOne(
db,
Job
.select(Job.Columns.nextRunTimestamp)
.filter(
[
Job.Behaviour.runOnce,
Job.Behaviour.recurring
].contains(Job.Columns.behaviour)
)
.order(Job.Columns.nextRunTimestamp)
)
}
guard let nextJobTimestamp: TimeInterval = nextJobTimestamp else { return }
// If the next job isn't scheduled in the future then just restart the JobRunner immediately
let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - Date().timeIntervalSince1970)
guard secondsUntilNextJob > 0 else {
SNLog("[JobRunner] Restarting immediately for job scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")) ago")
internalQueue.async {
JobRunner.start()
}
return
}
let targetTimestamp: TimeInterval = (maybeJob?.nextRunTimestamp ?? (Date().timeIntervalSince1970 + minRetryInterval))
nextTrigger.mutate { $0 = Trigger.create(timestamp: targetTimestamp) }
// Setup a trigger
SNLog("[JobRunner] Stopping until next job in \(Int(ceil(abs(secondsUntilNextJob))))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s"))")
nextTrigger.mutate { $0 = Trigger.create(timestamp: nextJobTimestamp) }
}
// MARK: - Handling Results
@ -316,13 +368,14 @@ public final class JobRunner {
try job.delete(db)
}
// For `recurring` jobs which have already run, they should automatically run again
// but we want at least 1 second to pass before doing so - the job itself should
// really update it's own 'nextRunTimestamp' (this is just a safety net)
case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970:
// For `recurring` jobs we want the job to run again but want at least 1 second to pass
GRDBStorage.shared.write { db in
var updatedJob: Job = job.with(
nextRunTimestamp: (Date().timeIntervalSince1970 + 1)
)
try updatedJob.save(db)
_ = try job
.with(nextRunTimestamp: (Date().timeIntervalSince1970 + 1))
.saved(db)
}
default: break

@ -7,6 +7,7 @@ public enum JobRunnerError: Error {
case executorMissing
case requiredThreadIdMissing
case requiredInteractionIdMissing
case missingRequiredDetails
}

@ -0,0 +1,309 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import PromiseKit
import SessionUtilitiesKit
import SessionSnodeKit
import SignalCoreKit
public enum AttachmentDownloadJob: JobExecutor {
public static var maxFailureCount: UInt = 10
public static var requiresThreadId: Bool = true
public static let requiresInteractionId: Bool = true
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
guard
let threadId: String = job.threadId,
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData),
var attachment: Attachment = GRDBStorage.shared
.read({ db in try Attachment.fetchOne(db, id: details.attachmentId) })
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
return
}
guard attachment.state != .downloaded else {
// FIXME: It's not clear * how * this happens, but apparently we can get to this point from time to time with an already downloaded attachment.
success(job, false)
return
}
// Update to the 'downloading' state
attachment = GRDBStorage.shared
.write { db in
try attachment
.with(state: .downloading)
.saved(db)
}
.defaulting(to: attachment)
let temporaryFilePath: URL = URL(
fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString
)
let downloadPromise: Promise<Data> = {
guard
let downloadUrl: String = attachment.downloadUrl,
let fileAsString: String = downloadUrl.split(separator: "/").last.map({ String($0) }),
let file: UInt64 = UInt64(fileAsString)
else {
return Promise(error: AttachmentDownloadError.invalidUrl)
}
if let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) {
return OpenGroupAPIV2.download(file, from: openGroup.room, on: openGroup.server)
}
return FileServerAPIV2.download(file, useOldServer: downloadUrl.contains(FileServerAPIV2.oldServer))
}()
downloadPromise
.then { data -> Promise<Void> in
try data.write(to: temporaryFilePath, options: .atomic)
let plaintext: Data = try {
guard
let key: Data = attachment.encryptionKey,
let digest: Data = attachment.digest,
key.count > 0,
digest.count > 0
else { return data } // Open group attachments are unencrypted
return try Cryptography.decryptAttachment(
data,
withKey: key,
digest: digest,
unpaddedSize: UInt32(attachment.byteCount)
)
}()
guard try attachment.write(data: plaintext) else {
throw AttachmentDownloadError.failedToSaveFile
}
return Promise.value(())
}
.done {
// Remove the temporary file
OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
// Update the attachment state
GRDBStorage.shared.write { db in
try attachment
.with(
state: .downloaded,
creationTimestamp: Date().timeIntervalSince1970,
localRelativeFilePath: attachment.originalFilePath?
.substring(from: Attachment.attachmentsFolder.count)
)
.save(db)
}
success(job, false)
}
.catch { error in
OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
switch error {
case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400:
// Otherwise, the attachment will show a state of downloading forever,
// and the message won't be able to be marked as read
GRDBStorage.shared.write { db in
try attachment
.with(state: .failed)
.save(db)
}
// This usually indicates a file that has expired on the server, so there's no need to retry
failure(job, error, true)
default:
failure(job, error, false)
}
}
}
}
// MARK: - AttachmentDownloadJob.Details
extension AttachmentDownloadJob {
public struct Details: Codable {
public let attachmentId: String
}
public enum AttachmentDownloadError: LocalizedError {
case failedToSaveFile
case invalidUrl
public var errorDescription: String? {
switch self {
case .failedToSaveFile: return "Failed to save file"
case .invalidUrl: return "Invalid file URL"
}
}
}
}
// TODO: MessageInvalidator.invalidate(tsMessage, with: transaction)
// public let attachmentID: String
// public let tsMessageID: String
// public let threadID: String
// public var delegate: JobDelegate?
// public var id: String?
// public var failureCount: UInt = 0
// public var isDeferred = false
//
// public enum Error : LocalizedError {
// case noAttachment
// case invalidURL
//
// public var errorDescription: String? {
// switch self {
// case .noAttachment: return "No such attachment."
// case .invalidURL: return "Invalid file URL."
// }
// }
// }
//
// // MARK: Settings
// public class var collection: String { return "AttachmentDownloadJobCollection" }
// public static let maxFailureCount: UInt = 20
//
// // MARK: Initialization
// public init(attachmentID: String, tsMessageID: String, threadID: String) {
// self.attachmentID = attachmentID
// self.tsMessageID = tsMessageID
// self.threadID = threadID
// }
//
// // MARK: Coding
// public init?(coder: NSCoder) {
// guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
// let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?,
// let threadID = coder.decodeObject(forKey: "threadID") as! String?,
// let id = coder.decodeObject(forKey: "id") as! String? else { return nil }
// self.attachmentID = attachmentID
// self.tsMessageID = tsMessageID
// self.threadID = threadID
// self.id = id
// self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
// self.isDeferred = coder.decodeBool(forKey: "isDeferred")
// }
//
// public func encode(with coder: NSCoder) {
// coder.encode(attachmentID, forKey: "attachmentID")
// coder.encode(tsMessageID, forKey: "tsIncomingMessageID")
// coder.encode(threadID, forKey: "threadID")
// coder.encode(id, forKey: "id")
// coder.encode(failureCount, forKey: "failureCount")
// coder.encode(isDeferred, forKey: "isDeferred")
// }
//
// // MARK: Running
// public func execute() {
// if let id = id {
// JobQueue.currentlyExecutingJobs.insert(id)
// }
// guard !isDeferred else { return }
// if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream {
// // FIXME: It's not clear * how * this happens, but apparently we can get to this point
// // from time to time with an already downloaded attachment.
// return handleSuccess()
// }
// guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else {
// return handleFailure(error: Error.noAttachment)
// }
// let storage = SNMessagingKitConfiguration.shared.storage
// storage.write(with: { transaction in
// storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction)
// }, completion: { })
// let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString)
// let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self
// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
// if let error = error as? Error, case .noAttachment = error {
// storage.write(with: { transaction in
// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction)
// }, completion: { })
// self.handlePermanentFailure(error: error)
// } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error,
// statusCode == 400 {
// // Otherwise, the attachment will show a state of downloading forever,
// // and the message won't be able to be marked as read.
// storage.write(with: { transaction in
// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction)
// }, completion: { })
// // This usually indicates a file that has expired on the server, so there's no need to retry.
// self.handlePermanentFailure(error: error)
// } else {
// self.handleFailure(error: error)
// }
// }
// if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) {
// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else {
// return handleFailure(Error.invalidURL)
// }
// OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in
// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure)
// }.catch(on: DispatchQueue.global()) { error in
// handleFailure(error)
// }
// } else {
// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else {
// return handleFailure(Error.invalidURL)
// }
// let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer)
// FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in
// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure)
// }.catch(on: DispatchQueue.global()) { error in
// handleFailure(error)
// }
// }
// }
//
// private func handleDownloadedAttachment(data: Data, temporaryFilePath: URL, pointer: TSAttachmentPointer, failureHandler: (Swift.Error) -> Void) {
// let storage = SNMessagingKitConfiguration.shared.storage
// do {
// try data.write(to: temporaryFilePath, options: .atomic)
// } catch {
// return failureHandler(error)
// }
// let plaintext: Data
// if let key = pointer.encryptionKey, let digest = pointer.digest, key.count > 0 && digest.count > 0 {
// do {
// plaintext = try Cryptography.decryptAttachment(data, withKey: key, digest: digest, unpaddedSize: pointer.byteCount)
// } catch {
// return failureHandler(error)
// }
// } else {
// plaintext = data // Open group attachments are unencrypted
// }
// let stream = TSAttachmentStream(pointer: pointer)
// do {
// try stream.write(plaintext)
// } catch {
// return failureHandler(error)
// }
// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
// storage.write(with: { transaction in
// storage.persist(stream, associatedWith: self.tsMessageID, using: transaction)
// }, completion: {
// self.handleSuccess()
// })
// }
//
// private func handleSuccess() {
// delegate?.handleJobSucceeded(self)
// }
//
// private func handlePermanentFailure(error: Swift.Error) {
// delegate?.handleJobFailedPermanently(self, with: error)
// }
//
// private func handleFailure(error: Swift.Error) {
// delegate?.handleJobFailed(self, with: error)
// }
//}

@ -7,6 +7,7 @@ import SessionUtilitiesKit
public enum DisappearingMessagesJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false
public static func run(
_ job: Job,
@ -64,7 +65,7 @@ public extension DisappearingMessagesJob {
.saved(db)
}
@discardableResult static func updateNextRunIfNeeded(_ db: Database, interactionIds: [Int64], startedAtMs: Double) -> Bool {
@discardableResult static func updateNextRunIfNeeded(_ db: Database, interactionIds: [Int64], startedAtMs: Double) -> Job? {
// Update the expiring messages expiresStartedAtMs value
let changeCount: Int? = try? Interaction
.filter(interactionIds.contains(Interaction.Columns.id))
@ -72,17 +73,17 @@ public extension DisappearingMessagesJob {
.updateAll(db, Interaction.Columns.expiresStartedAtMs.set(to: startedAtMs))
// If there were no changes then none of the provided `interactionIds` are expiring messages
guard (changeCount ?? 0) > 0 else { return false }
guard (changeCount ?? 0) > 0 else { return nil }
return (updateNextRunIfNeeded(db) != nil)
return updateNextRunIfNeeded(db)
}
@discardableResult static func updateNextRunIfNeeded(_ db: Database, interaction: Interaction, startedAtMs: Double) -> Bool {
guard interaction.isExpiringMessage else { return false }
@discardableResult static func updateNextRunIfNeeded(_ db: Database, interaction: Interaction, startedAtMs: Double) -> Job? {
guard interaction.isExpiringMessage else { return nil }
// Don't clobber if multiple actions simultaneously triggered expiration
guard interaction.expiresStartedAtMs == nil || (interaction.expiresStartedAtMs ?? 0) > startedAtMs else {
return false
return nil
}
do {
@ -94,7 +95,7 @@ public extension DisappearingMessagesJob {
}
catch {
SNLog("Failed to update the expiring messages timer on an interaction")
return false
return nil
}
}
}

@ -8,6 +8,7 @@ import SessionUtilitiesKit
public enum FailedAttachmentDownloadsJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false
public static func run(
_ job: Job,

@ -8,6 +8,7 @@ import SessionUtilitiesKit
public enum FailedMessagesJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false
public static func run(
_ job: Job,

@ -7,6 +7,7 @@ import SessionUtilitiesKit
public enum MessageReceiveJob: JobExecutor {
public static var maxFailureCount: UInt = 10
public static var requiresThreadId: Bool = true
public static let requiresInteractionId: Bool = false
public static func run(
_ job: Job,

@ -9,6 +9,7 @@ import SessionSnodeKit
public enum MessageSendJob: JobExecutor {
public static var maxFailureCount: UInt = 10
public static var requiresThreadId: Bool = true
public static let requiresInteractionId: Bool = false // Some messages don't have interactions
public static func run(
_ job: Job,

@ -8,6 +8,7 @@ import SessionUtilitiesKit
public enum NotifyPushServerJob: JobExecutor {
public static var maxFailureCount: UInt = 20
public static var requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false
public static func run(
_ job: Job,
@ -36,7 +37,7 @@ public enum NotifyPushServerJob: JobExecutor {
"Content-Type": "application/json"
]
let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) {
attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) {
OnionRequestAPI
.sendOnionRequest(
request,
@ -49,11 +50,10 @@ public enum NotifyPushServerJob: JobExecutor {
.done { _ in
success(job, false)
}
promise.catch { error in
.`catch` { error in
failure(job, error, false)
}
promise.retainUntilComplete()
.retainUntilComplete()
}
}

@ -8,6 +8,7 @@ import SessionUtilitiesKit
public enum SendReadReceiptsJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false
private static let minRunFrequency: TimeInterval = 3
public static func run(

@ -22,7 +22,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[];
#import <SessionMessagingKit/OWSReadReceiptManager.h>
#import <SessionMessagingKit/OWSReadTracking.h>
#import <SessionMessagingKit/OWSRecipientIdentity.h>
#import <SessionMessagingKit/OWSSounds.h>
#import <SessionMessagingKit/OWSStorage.h>
#import <SessionMessagingKit/OWSStorage+Subclass.h>
#import <SessionMessagingKit/OWSUserProfile.h>

@ -9,6 +9,10 @@ import SessionUtilitiesKit
extension MessageReceiver {
/// Extract the sender public key (used as the threadId in contact threads)
///
/// **Note:** This is a slightly optimised version of the `decryptWithSessionProtocol` function which just skips
/// the validation (handled when the job actually runs) and doesn't throw
internal static func extractSenderPublicKey(_ db: Database, from envelope: SNProtoEnvelope) -> String? {
guard
let ciphertext: Data = envelope.content,

@ -594,8 +594,9 @@ extension MessageReceiver {
db,
job: Job(
variant: .attachmentDownload,
threadId: thread.id,
interactionId: interactionId,
details: AttachmentDownloadJob.Details(
threadId: thread.id,
attachmentId: attachmentId
)
),
@ -818,7 +819,8 @@ extension MessageReceiver {
Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction)
// Store the key pair
try ClosedGroupKeyPair(
publicKey: groupPublicKey,
threadId: groupPublicKey,
publicKey: Data(encryptionKeyPair.publicKey),
secretKey: Data(encryptionKeyPair.secretKey),
receivedTimestamp: Date().timeIntervalSince1970
).insert(db)
@ -876,24 +878,18 @@ extension MessageReceiver {
return SNLog("Couldn't parse closed group encryption key pair.")
}
let keyPair: Box.KeyPair = Box.KeyPair(
publicKey: proto.publicKey.removing05PrefixIfNeeded().bytes,
secretKey: proto.privateKey.bytes
)
// Store it if needed
let keyPairs: [ClosedGroupKeyPair] = ((try? closedGroup.keyPairs.fetchAll(db)) ?? [])
let secretKeyData: Data = Data(keyPair.secretKey)
guard !keyPairs.contains(where: { $0.secretKey == secretKeyData }) else {
do {
try ClosedGroupKeyPair(
threadId: groupPublicKey,
publicKey: proto.publicKey.removing05PrefixIfNeeded(),
secretKey: proto.privateKey,
receivedTimestamp: Date().timeIntervalSince1970
).insert(db)
}
catch {
return SNLog("Ignoring duplicate closed group encryption key pair.")
}
try ClosedGroupKeyPair(
publicKey: keyPair.publicKey.toHexString(), // Should match 'groupPublicKey'
secretKey: secretKeyData,
receivedTimestamp: Date().timeIntervalSince1970
).insert(db)
SNLog("Received a new closed group encryption key pair.")
}
@ -1019,8 +1015,8 @@ extension MessageReceiver {
// Stop polling for the group
// Remove the key pairs associated with the group
// Notify the PN server
let userPublicKey = getUserHexEncodedPublicKey()
let wasCurrentUserRemoved = !members.contains(userPublicKey)
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey)
if wasCurrentUserRemoved {
ClosedGroupPoller.shared.stopPolling(for: id)

@ -66,7 +66,7 @@ public enum MessageReceiver {
return try decryptWithSessionProtocol(
ciphertext: ciphertext,
using: Box.KeyPair(
publicKey: Data(hex: keyPair.publicKey).bytes,
publicKey: keyPair.publicKey.bytes,
secretKey: keyPair.secretKey.bytes
)
)

@ -8,7 +8,7 @@ import PromiseKit
import SessionUtilitiesKit
extension MessageSender {
public static var distributingClosedGroupEncryptionKeyPairs: [String: [Box.KeyPair]] = [:]
public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:])
public static func createClosedGroup(_ db: Database, name: String, members: Set<String>) throws -> Promise<SessionThread> {
let userPublicKey: String = getUserHexEncodedPublicKey()
@ -18,10 +18,6 @@ extension MessageSender {
let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the "05" prefix
// Generate the key pair that'll be used for encryption and decryption
let encryptionKeyPair = Curve25519.generateKeyPair()
let keyPair: Box.KeyPair = Box.KeyPair(
publicKey: encryptionKeyPair.publicKey.bytes,
secretKey: encryptionKeyPair.privateKey.bytes
)
// Create the group
members.insert(userPublicKey) // Ensure the current user is included in the member list
@ -72,7 +68,10 @@ extension MessageSender {
kind: .new(
publicKey: Data(hex: groupPublicKey),
name: name,
encryptionKeyPair: keyPair,
encryptionKeyPair: Box.KeyPair(
publicKey: encryptionKeyPair.publicKey.bytes,
secretKey: encryptionKeyPair.privateKey.bytes
),
members: membersAsData,
admins: adminsAsData,
expirationTimer: 0
@ -86,8 +85,9 @@ extension MessageSender {
// Store the key pair
try ClosedGroupKeyPair(
publicKey: keyPair.publicKey.toHexString(),
secretKey: Data(keyPair.secretKey),
threadId: groupPublicKey,
publicKey: encryptionKeyPair.publicKey,
secretKey: encryptionKeyPair.privateKey,
receivedTimestamp: Date().timeIntervalSince1970
).insert(db)
@ -134,20 +134,25 @@ extension MessageSender {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
// Generate the new encryption key pair
let newLegacyKeyPair = Curve25519.generateKeyPair()
let newKeyPair: Box.KeyPair = Box.KeyPair(
publicKey: newLegacyKeyPair.publicKey.bytes,
secretKey: newLegacyKeyPair.privateKey.bytes
let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair()
let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair(
threadId: closedGroup.threadId,
publicKey: legacyNewKeyPair.publicKey,
secretKey: legacyNewKeyPair.privateKey,
receivedTimestamp: Date().timeIntervalSince1970
)
// Distribute it
let proto = try SNProtoKeyPair.builder(publicKey: Data(newKeyPair.publicKey),
privateKey: Data(newKeyPair.secretKey)).build()
let proto = try SNProtoKeyPair.builder(
publicKey: newKeyPair.publicKey,
privateKey: newKeyPair.secretKey
).build()
let plaintext = try proto.serializedData()
var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? [])
distributingKeyPairs.append(newKeyPair)
distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs
distributingKeyPairs.mutate {
$0[closedGroup.id] = ($0[closedGroup.id] ?? [])
.appending(newKeyPair)
}
do {
return try MessageSender
@ -173,17 +178,13 @@ extension MessageSender {
.done {
/// Store it **after** having sent out the message to the group
GRDBStorage.shared.write { db in
try ClosedGroupKeyPair(
publicKey: newKeyPair.publicKey.toHexString(),
secretKey: Data(newKeyPair.secretKey),
receivedTimestamp: Date().timeIntervalSince1970
).insert(db)
var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? [])
try newKeyPair.insert(db)
if let index = distributingKeyPairs.firstIndex(of: newKeyPair) {
distributingKeyPairs.remove(at: index)
distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs
distributingKeyPairs.mutate {
if let index = ($0[closedGroup.id] ?? []).firstIndex(of: newKeyPair) {
$0[closedGroup.id] = ($0[closedGroup.id] ?? [])
.removing(index: index)
}
}
}
}
@ -607,26 +608,19 @@ extension MessageSender {
}
// Get the latest encryption key pair
var maybeEncryptionKeyPair: Box.KeyPair? = distributingClosedGroupEncryptionKeyPairs[groupPublicKey]?.last
var maybeKeyPair: ClosedGroupKeyPair? = distributingKeyPairs.wrappedValue[groupPublicKey]?.last
if maybeEncryptionKeyPair == nil {
guard let encryptionKeyPair: ClosedGroupKeyPair = try? closedGroup.fetchLatestKeyPair(db) else {
return
}
maybeEncryptionKeyPair = Box.KeyPair(
publicKey: Data(hex: encryptionKeyPair.publicKey).bytes,
secretKey: encryptionKeyPair.secretKey.bytes
)
if maybeKeyPair == nil {
maybeKeyPair = try? closedGroup.fetchLatestKeyPair(db)
}
guard let encryptionKeyPair: Box.KeyPair = maybeEncryptionKeyPair else { return }
guard let keyPair: ClosedGroupKeyPair = maybeKeyPair else { return }
// Send it
do {
let proto = try SNProtoKeyPair.builder(
publicKey: Data(encryptionKeyPair.publicKey),
privateKey: Data(encryptionKeyPair.secretKey)
publicKey: keyPair.publicKey,
privateKey: keyPair.secretKey
).build()
let plaintext = try proto.serializedData()
let thread: SessionThread = try SessionThread

@ -182,11 +182,14 @@ public final class MessageSender : NSObject {
ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey)
case .closedGroup(let groupPublicKey):
guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, publicKey: groupPublicKey) else {
guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else {
throw MessageSenderError.noKeyPair
}
ciphertext = try encryptWithSessionProtocol(plaintext, for: "05\(encryptionKeyPair.publicKey)")
ciphertext = try encryptWithSessionProtocol(
plaintext,
for: "05\(encryptionKeyPair.publicKey.toHexString())"
)
case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure()
}
@ -357,7 +360,7 @@ public final class MessageSender : NSObject {
#if DEBUG
preconditionFailure()
#else
handleFailure(with: Error.invalidMessage, using: transaction)
handleFailure(db, with: MessageSenderError.invalidMessage)
return promise
#endif
}
@ -461,10 +464,16 @@ public final class MessageSender : NSObject {
NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil)
// Start the disappearing messages timer if needed
DisappearingMessagesJob.updateNextRunIfNeeded(
JobRunner.upsert(
db,
interaction: interaction,
startedAtMs: (Date().timeIntervalSince1970 * 1000)
job: Job(
variant: .disappearingMessages,
details: DisappearingMessagesJob.updateNextRunIfNeeded(
db,
interaction: interaction,
startedAtMs: (Date().timeIntervalSince1970 * 1000)
)
)
)
}

@ -109,11 +109,8 @@ public final class Poller : NSObject {
messages.forEach { message in
guard let envelope = SNProtoEnvelope.from(message) else { return }
// Extract the sender public key (used as the threadId in contact threads) and add
// that to the messageReceive job for multi-threading and garbage collection purposes
//
// Note: This is a slightly optimised version of the message decryption which
// just skips the validation (handled when the job actually runs) and doesn't throw
// Extract the threadId and add that to the messageReceive job for
// multi-threading and garbage collection purposes
let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope)
if threadId == nil {

@ -2,7 +2,6 @@
@class OWSAudioSession;
@class OWSPreferences;
@class OWSSounds;
@class OWSWindowManager;
@protocol OWSProximityMonitoringManager;
@ -21,13 +20,11 @@
- (instancetype)initWithAudioSession:(OWSAudioSession *)audioSession
preferences:(OWSPreferences *)preferences
proximityMonitoringManager:(id<OWSProximityMonitoringManager>)proximityMonitoringManager
sounds:(OWSSounds *)sounds
windowManager:(OWSWindowManager *)windowManager;
@property (nonatomic, readonly) OWSAudioSession *audioSession;
@property (nonatomic, readonly) id<OWSProximityMonitoringManager> proximityMonitoringManager;
@property (nonatomic, readonly) OWSPreferences *preferences;
@property (nonatomic, readonly) OWSSounds *sounds;
@property (nonatomic, readonly) OWSWindowManager *windowManager;
// We don't want to cover the window when we request the photo library permission
@property (nonatomic, readwrite) BOOL isRequestingPermission;

@ -3,7 +3,6 @@
#import "OWSWindowManager.h"
#import <SessionMessagingKit/SessionMessagingKit-Swift.h>
#import "OWSPreferences.h"
#import "OWSSounds.h"
static Environment *sharedEnvironment = nil;
@ -12,7 +11,6 @@ static Environment *sharedEnvironment = nil;
@property (nonatomic) OWSAudioSession *audioSession;
@property (nonatomic) OWSPreferences *preferences;
@property (nonatomic) id<OWSProximityMonitoringManager> proximityMonitoringManager;
@property (nonatomic) OWSSounds *sounds;
@property (nonatomic) OWSWindowManager *windowManager;
@end
@ -44,7 +42,6 @@ static Environment *sharedEnvironment = nil;
- (instancetype)initWithAudioSession:(OWSAudioSession *)audioSession
preferences:(OWSPreferences *)preferences
proximityMonitoringManager:(id<OWSProximityMonitoringManager>)proximityMonitoringManager
sounds:(OWSSounds *)sounds
windowManager:(OWSWindowManager *)windowManager
{
self = [super init];
@ -56,7 +53,6 @@ static Environment *sharedEnvironment = nil;
_audioSession = audioSession;
_preferences = preferences;
_proximityMonitoringManager = proximityMonitoringManager;
_sounds = sounds;
_windowManager = windowManager;
_isRequestingPermission = false;

@ -1,79 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import <AudioToolbox/AudioServices.h>
#import <Foundation/Foundation.h>
#import "OWSAudioPlayer.h"
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSUInteger, OWSSound) {
OWSSound_Default = 0,
// Notification Sounds
OWSSound_Aurora,
OWSSound_Bamboo,
OWSSound_Chord,
OWSSound_Circles,
OWSSound_Complete,
OWSSound_Hello,
OWSSound_Input,
OWSSound_Keys,
OWSSound_Note,
OWSSound_Popcorn,
OWSSound_Pulse,
OWSSound_Synth,
OWSSound_SignalClassic,
// Ringtone Sounds
OWSSound_Opening,
// Calls
OWSSound_CallConnecting,
OWSSound_CallOutboundRinging,
OWSSound_CallBusy,
OWSSound_CallFailure,
// Other
OWSSound_MessageSent,
OWSSound_None,
OWSSound_DefaultiOSIncomingRingtone = OWSSound_Opening,
};
@class OWSAudioPlayer;
@class OWSPrimaryStorage;
@class TSThread;
@class YapDatabaseReadWriteTransaction;
@interface OWSSounds : NSObject
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER;
+ (NSString *)displayNameForSound:(OWSSound)sound;
+ (nullable NSString *)filenameForSound:(OWSSound)sound;
+ (nullable NSString *)filenameForSound:(OWSSound)sound quiet:(BOOL)quiet;
#pragma mark - Notifications
+ (NSArray<NSNumber *> *)allNotificationSounds;
+ (OWSSound)globalNotificationSound;
+ (void)setGlobalNotificationSound:(OWSSound)sound;
+ (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction;
+ (OWSSound)notificationSoundForThread:(TSThread *)thread;
+ (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet;
+ (void)setNotificationSound:(OWSSound)sound forThread:(TSThread *)thread;
#pragma mark - AudioPlayer
+ (nullable OWSAudioPlayer *)audioPlayerForSound:(OWSSound)sound
audioBehavior:(OWSAudioBehavior)audioBehavior;
@end
NS_ASSUME_NONNULL_END

@ -1,365 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "OWSSounds.h"
#import "Environment.h"
#import "OWSAudioPlayer.h"
#import <SessionUtilitiesKit/SessionUtilitiesKit.h>
#import <SessionMessagingKit/OWSPrimaryStorage.h>
#import <SessionMessagingKit/TSThread.h>
#import <SessionMessagingKit/YapDatabaseConnection+OWS.h>
#import <YapDatabase/YapDatabase.h>
NSString *const kOWSSoundsStorageNotificationCollection = @"kOWSSoundsStorageNotificationCollection";
NSString *const kOWSSoundsStorageGlobalNotificationKey = @"kOWSSoundsStorageGlobalNotificationKey";
@interface OWSSystemSound : NSObject
@property (nonatomic, readonly) SystemSoundID soundID;
@property (nonatomic, readonly) NSURL *soundURL;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithURL:(NSURL *)url NS_DESIGNATED_INITIALIZER;
@end
@implementation OWSSystemSound
- (instancetype)initWithURL:(NSURL *)url
{
self = [super init];
if (!self) {
return self;
}
_soundURL = url;
SystemSoundID newSoundID;
_soundID = newSoundID;
return self;
}
- (void)dealloc
{
}
@end
@interface OWSSounds ()
@property (nonatomic, readonly) YapDatabaseConnection *dbConnection;
@property (nonatomic, readonly) AnyLRUCache *cachedSystemSounds;
@end
#pragma mark -
@implementation OWSSounds
+ (instancetype)sharedManager
{
return Environment.shared.sounds;
}
- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage
{
self = [super init];
if (!self) {
return self;
}
_dbConnection = primaryStorage.newDatabaseConnection;
// Don't store too many sounds in memory. Most users will only use 1 or 2 sounds anyway.
_cachedSystemSounds = [[AnyLRUCache alloc] initWithMaxSize:4];
return self;
}
+ (NSArray<NSNumber *> *)allNotificationSounds
{
return @[
// None and Note (default) should be first.
@(OWSSound_None),
@(OWSSound_Note),
@(OWSSound_Aurora),
@(OWSSound_Bamboo),
@(OWSSound_Chord),
@(OWSSound_Circles),
@(OWSSound_Complete),
@(OWSSound_Hello),
@(OWSSound_Input),
@(OWSSound_Keys),
@(OWSSound_Popcorn),
@(OWSSound_Pulse),
@(OWSSound_Synth),
];
}
+ (NSString *)displayNameForSound:(OWSSound)sound
{
// TODO: Should we localize these sound names?
switch (sound) {
case OWSSound_Default:
return @"";
// Notification Sounds
case OWSSound_Aurora:
return @"Aurora";
case OWSSound_Bamboo:
return @"Bamboo";
case OWSSound_Chord:
return @"Chord";
case OWSSound_Circles:
return @"Circles";
case OWSSound_Complete:
return @"Complete";
case OWSSound_Hello:
return @"Hello";
case OWSSound_Input:
return @"Input";
case OWSSound_Keys:
return @"Keys";
case OWSSound_Note:
return @"Note";
case OWSSound_Popcorn:
return @"Popcorn";
case OWSSound_Pulse:
return @"Pulse";
case OWSSound_Synth:
return @"Synth";
case OWSSound_SignalClassic:
return @"Signal Classic";
// Call Audio
case OWSSound_Opening:
return @"Opening";
case OWSSound_CallConnecting:
return @"Call Connecting";
case OWSSound_CallOutboundRinging:
return @"Call Outboung Ringing";
case OWSSound_CallBusy:
return @"Call Busy";
case OWSSound_CallFailure:
return @"Call Failure";
case OWSSound_MessageSent:
return @"Message Sent";
// Other
case OWSSound_None:
return NSLocalizedString(@"SOUNDS_NONE",
@"Label for the 'no sound' option that allows users to disable sounds for notifications, "
@"etc.");
}
}
+ (nullable NSString *)filenameForSound:(OWSSound)sound
{
return [self filenameForSound:sound quiet:NO];
}
+ (nullable NSString *)filenameForSound:(OWSSound)sound quiet:(BOOL)quiet
{
switch (sound) {
case OWSSound_Default:
return @"";
// Notification Sounds
case OWSSound_Aurora:
return (quiet ? @"aurora-quiet.aifc" : @"aurora.aifc");
case OWSSound_Bamboo:
return (quiet ? @"bamboo-quiet.aifc" : @"bamboo.aifc");
case OWSSound_Chord:
return (quiet ? @"chord-quiet.aifc" : @"chord.aifc");
case OWSSound_Circles:
return (quiet ? @"circles-quiet.aifc" : @"circles.aifc");
case OWSSound_Complete:
return (quiet ? @"complete-quiet.aifc" : @"complete.aifc");
case OWSSound_Hello:
return (quiet ? @"hello-quiet.aifc" : @"hello.aifc");
case OWSSound_Input:
return (quiet ? @"input-quiet.aifc" : @"input.aifc");
case OWSSound_Keys:
return (quiet ? @"keys-quiet.aifc" : @"keys.aifc");
case OWSSound_Note:
return (quiet ? @"note-quiet.aifc" : @"note.aifc");
case OWSSound_Popcorn:
return (quiet ? @"popcorn-quiet.aifc" : @"popcorn.aifc");
case OWSSound_Pulse:
return (quiet ? @"pulse-quiet.aifc" : @"pulse.aifc");
case OWSSound_Synth:
return (quiet ? @"synth-quiet.aifc" : @"synth.aifc");
case OWSSound_SignalClassic:
return (quiet ? @"classic-quiet.aifc" : @"classic.aifc");
// Ringtone Sounds
case OWSSound_Opening:
return @"Opening.m4r";
// Calls
case OWSSound_CallConnecting:
return @"ringback_tone_ansi.caf";
case OWSSound_CallOutboundRinging:
return @"ringback_tone_ansi.caf";
case OWSSound_CallBusy:
return @"busy_tone_ansi.caf";
case OWSSound_CallFailure:
return @"end_call_tone_cept.caf";
case OWSSound_MessageSent:
return @"message_sent.aiff";
// Other
case OWSSound_None:
return nil;
}
}
+ (nullable NSURL *)soundURLForSound:(OWSSound)sound quiet:(BOOL)quiet
{
NSString *_Nullable filename = [self filenameForSound:sound quiet:quiet];
if (!filename) {
return nil;
}
NSURL *_Nullable url = [[NSBundle mainBundle] URLForResource:filename.stringByDeletingPathExtension
withExtension:filename.pathExtension];
return url;
}
+ (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet
{
return [self.sharedManager systemSoundIDForSound:(OWSSound)sound quiet:quiet];
}
- (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet
{
NSString *cacheKey = [NSString stringWithFormat:@"%lu:%d", (unsigned long)sound, quiet];
OWSSystemSound *_Nullable cachedSound = (OWSSystemSound *)[self.cachedSystemSounds getWithKey:cacheKey];
if (cachedSound) {
return cachedSound.soundID;
}
NSURL *soundURL = [self.class soundURLForSound:sound quiet:quiet];
OWSSystemSound *newSound = [[OWSSystemSound alloc] initWithURL:soundURL];
[self.cachedSystemSounds setWithKey:cacheKey value:newSound];
return newSound.soundID;
}
#pragma mark - Notifications
+ (OWSSound)defaultNotificationSound
{
return OWSSound_Note;
}
+ (OWSSound)globalNotificationSound
{
OWSSounds *instance = OWSSounds.sharedManager;
NSNumber *_Nullable value = [instance.dbConnection objectForKey:kOWSSoundsStorageGlobalNotificationKey
inCollection:kOWSSoundsStorageNotificationCollection];
// Default to the global default.
return (value ? (OWSSound)value.intValue : [self defaultNotificationSound]);
}
+ (void)setGlobalNotificationSound:(OWSSound)sound
{
[self.sharedManager setGlobalNotificationSound:sound];
}
- (void)setGlobalNotificationSound:(OWSSound)sound
{
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
[self setGlobalNotificationSound:sound transaction:transaction];
}];
}
+ (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction
{
[self.sharedManager setGlobalNotificationSound:sound transaction:transaction];
}
- (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction
{
// Fallback push notifications play a sound specified by the server, but we don't want to store this configuration
// on the server. Instead, we create a file with the same name as the default to be played when receiving
// a fallback notification.
NSString *dirPath = [[OWSFileSystem appLibraryDirectoryPath] stringByAppendingPathComponent:@"Sounds"];
[OWSFileSystem ensureDirectoryExists:dirPath];
// This name is specified in the payload by the Signal Service when requesting fallback push notifications.
NSString *kDefaultNotificationSoundFilename = @"NewMessage.aifc";
NSString *defaultSoundPath = [dirPath stringByAppendingPathComponent:kDefaultNotificationSoundFilename];
NSURL *_Nullable soundURL = [OWSSounds soundURLForSound:sound quiet:NO];
NSData *soundData = ^{
if (soundURL) {
return [NSData dataWithContentsOfURL:soundURL];
} else {
return [NSData new];
}
}();
// Quick way to achieve an atomic "copy" operation that allows overwriting if the user has previously specified
// a default notification sound.
BOOL success = [soundData writeToFile:defaultSoundPath atomically:YES];
// The globally configured sound the user has configured is unprotected, so that we can still play the sound if the
// user hasn't authenticated after power-cycling their device.
[OWSFileSystem protectFileOrFolderAtPath:defaultSoundPath fileProtectionType:NSFileProtectionNone];
if (!success) {
return;
}
[transaction setObject:@(sound)
forKey:kOWSSoundsStorageGlobalNotificationKey
inCollection:kOWSSoundsStorageNotificationCollection];
}
+ (OWSSound)notificationSoundForThread:(TSThread *)thread
{
OWSSounds *instance = OWSSounds.sharedManager;
NSNumber *_Nullable value =
[instance.dbConnection objectForKey:thread.uniqueId inCollection:kOWSSoundsStorageNotificationCollection];
// Default to the "global" notification sound, which in turn will default to the global default.
return (value ? (OWSSound)value.intValue : [self globalNotificationSound]);
}
+ (void)setNotificationSound:(OWSSound)sound forThread:(TSThread *)thread
{
OWSSounds *instance = OWSSounds.sharedManager;
[instance.dbConnection setObject:@(sound)
forKey:thread.uniqueId
inCollection:kOWSSoundsStorageNotificationCollection];
}
#pragma mark - AudioPlayer
+ (BOOL)shouldAudioPlayerLoopForSound:(OWSSound)sound
{
return (sound == OWSSound_CallConnecting || sound == OWSSound_CallOutboundRinging);
}
+ (nullable OWSAudioPlayer *)audioPlayerForSound:(OWSSound)sound
audioBehavior:(OWSAudioBehavior)audioBehavior;
{
NSURL *_Nullable soundURL = [OWSSounds soundURLForSound:sound quiet:NO];
if (!soundURL) {
return nil;
}
OWSAudioPlayer *player = [[OWSAudioPlayer alloc] initWithMediaUrl:soundURL audioBehavior:audioBehavior];
if ([self shouldAudioPlayerLoopForSound:sound]) {
player.isLooping = YES;
}
return player;
}
@end

@ -1,15 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SignalCoreKit
extension OWSSound {
public func notificationSound(isQuiet: Bool) -> UNNotificationSound {
guard let filename = OWSSounds.filename(for: self, quiet: isQuiet) else {
owsFailDebug("filename was unexpectedly nil")
return UNNotificationSound.default
}
return UNNotificationSound(named: UNNotificationSoundName(rawValue: filename))
}
}

@ -3,6 +3,7 @@
import Foundation
import GRDB
import SessionUtilitiesKit
import AudioToolbox
public extension Setting.EnumKey {
/// Controls how notifications should appear for the user (See `NotificationPreviewType` for the options)
@ -66,8 +67,15 @@ public enum Preferences {
}
public enum Sound: Int, Codable, DatabaseValueConvertible, EnumSetting {
static var defaultiOSIncomingRingtone: Sound = .opening
static var defaultNotificationSound: Sound = .note
public static var defaultiOSIncomingRingtone: Sound = .opening
public static var defaultNotificationSound: Sound = .note
// Don't store too many sounds in memory (Most users will only use 1 or 2 sounds anyway)
private static let maxCachedSounds: Int = 4
private static var cachedSystemSounds: Atomic<[String: (url: URL?, soundId: SystemSoundID)]> = Atomic([:])
private static var cachedSystemSoundOrder: Atomic<[String]> = Atomic([])
// Values
case `default`
@ -99,7 +107,7 @@ public enum Preferences {
case messageSent = 4000
case none
static var notificationSounds: [Sound] {
public static var notificationSounds: [Sound] {
return [
// None and Note (default) should be first.
.none,
@ -118,5 +126,202 @@ public enum Preferences {
.synth
]
}
var displayName: String {
// TODO: Should we localize these sound names?
switch self {
case .`default`: return ""
// Notification Sounds
case .aurora: return "Aurora"
case .bamboo: return "Bamboo"
case .chord: return "Chord"
case .circles: return "Circles"
case .complete: return "Complete"
case .hello: return "Hello"
case .input: return "Input"
case .keys: return "Keys"
case .note: return "Note"
case .popcorn: return "Popcorn"
case .pulse: return "Pulse"
case .synth: return "Synth"
case .signalClassic: return "Signal Classic"
// Ringtone Sounds
case .opening: return "Opening"
// Calls
case .callConnecting: return "Call Connecting"
case .callOutboundRinging: return "Call Outboung Ringing"
case .callBusy: return "Call Busy"
case .callFailure: return "Call Failure"
// Other
case .messageSent: return "Message Sent"
case .none: return "SOUNDS_NONE".localized()
}
}
// MARK: - Functions
public func filename(quiet: Bool = false) -> String? {
switch self {
case .`default`: return ""
// Notification Sounds
case .aurora: return (quiet ? "aurora-quiet.aifc" : "aurora.aifc")
case .bamboo: return (quiet ? "bamboo-quiet.aifc" : "bamboo.aifc")
case .chord: return (quiet ? "chord-quiet.aifc" : "chord.aifc")
case .circles: return (quiet ? "circles-quiet.aifc" : "circles.aifc")
case .complete: return (quiet ? "complete-quiet.aifc" : "complete.aifc")
case .hello: return (quiet ? "hello-quiet.aifc" : "hello.aifc")
case .input: return (quiet ? "input-quiet.aifc" : "input.aifc")
case .keys: return (quiet ? "keys-quiet.aifc" : "keys.aifc")
case .note: return (quiet ? "note-quiet.aifc" : "note.aifc")
case .popcorn: return (quiet ? "popcorn-quiet.aifc" : "popcorn.aifc")
case .pulse: return (quiet ? "pulse-quiet.aifc" : "pulse.aifc")
case .synth: return (quiet ? "synth-quiet.aifc" : "synth.aifc")
case .signalClassic: return (quiet ? "classic-quiet.aifc" : "classic.aifc")
// Ringtone Sounds
case .opening: return "Opening.m4r"
// Calls
case .callConnecting: return "ringback_tone_ansi.caf"
case .callOutboundRinging: return "ringback_tone_ansi.caf"
case .callBusy: return "busy_tone_ansi.caf"
case .callFailure: return "end_call_tone_cept.caf"
// Other
case .messageSent: return "message_sent.aiff"
case .none: return nil
}
}
public func soundUrl(quiet: Bool = false) -> URL? {
guard let filename: String = filename(quiet: quiet) else { return nil }
let url: URL = URL(fileURLWithPath: filename)
return Bundle.main.url(
forResource: url.deletingPathExtension().absoluteString,
withExtension: url.pathExtension
)
}
public func notificationSound(isQuiet: Bool) -> UNNotificationSound {
guard let filename: String = filename(quiet: isQuiet) else {
SNLog("[Preferences.Sound] filename was unexpectedly nil")
return UNNotificationSound.default
}
return UNNotificationSound(named: UNNotificationSoundName(rawValue: filename))
}
public static func systemSoundId(for sound: Sound, quiet: Bool) -> SystemSoundID {
let cacheKey: String = "\(sound.rawValue):\(quiet ? 1 : 0)"
if let cachedSound: SystemSoundID = cachedSystemSounds.wrappedValue[cacheKey]?.soundId {
return cachedSound
}
let systemSound: (url: URL?, soundId: SystemSoundID) = (
url: sound.soundUrl(quiet: quiet),
soundId: SystemSoundID()
)
cachedSystemSounds.mutate { cache in
cachedSystemSoundOrder.mutate { order in
if order.count > Sound.maxCachedSounds {
cache.removeValue(forKey: order[0])
order.remove(at: 0)
}
order.append(cacheKey)
}
cache[cacheKey] = systemSound
}
return systemSound.soundId
}
// MARK: - AudioPlayer
public static func audioPlayer(for sound: Sound, behaviour: OWSAudioBehavior) -> OWSAudioPlayer? {
guard let soundUrl: URL = sound.soundUrl(quiet: false) else { return nil }
let player = OWSAudioPlayer(mediaUrl: soundUrl, audioBehavior: behaviour)
// These two cases should loop
if sound == .callConnecting || sound == .callOutboundRinging {
player.isLooping = true
}
return player
}
}
}
// MARK: - Objective C Support
// FIXME: Remove this once the 'NotificationSettingsViewController' and 'OWSSoundSettingsViewController' have been refactored to Swift
@objc(SMKSound)
public class SMKSound: NSObject {
@objc public static var notificationSounds: [Int] = Preferences.Sound.notificationSounds.map { $0.rawValue }
@objc public static func displayName(for sound: Int) -> String {
return (Preferences.Sound(rawValue: sound) ?? Preferences.Sound.default).displayName
}
@objc public static func isNote(_ sound: Int) -> Bool {
return (sound == Preferences.Sound.note.rawValue)
}
@objc public static func audioPlayer(for sound: Int, audioBehavior: OWSAudioBehavior) -> OWSAudioPlayer? {
guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return nil }
return Preferences.Sound.audioPlayer(for: sound, behaviour: audioBehavior)
}
@objc public static var defaultNotificationSound: Int {
GRDBStorage.shared[.defaultNotificationSound]
.defaulting(to: Preferences.Sound.defaultNotificationSound)
.rawValue
}
@objc public static func setGlobalNotificationSound(_ sound: Int) {
guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return }
GRDBStorage.shared.write { db in
db[.defaultNotificationSound] = sound
}
}
@objc public static func notificationSound(for threadId: String?) -> Int {
guard let threadId: String = threadId else { return defaultNotificationSound }
return (GRDBStorage.shared
.read { db in
try Preferences.Sound
.fetchOne(
db,
SessionThread
.select(SessionThread.Columns.notificationSound)
.filter(id: threadId)
)
}?
.rawValue)
.defaulting(to: defaultNotificationSound)
}
@objc public static func setNotificationSound(_ sound: Int, forThreadId threadId: String) {
guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return }
GRDBStorage.shared.write { db in
try SessionThread
.filter(id: threadId)
.updateAll(db, SessionThread.Columns.notificationSound.set(to: sound))
}
}
}

@ -74,7 +74,8 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol {
let notificationContent = UNMutableNotificationContent()
notificationContent.userInfo = userInfo
notificationContent.sound = OWSSounds.notificationSound(forThreadId: thread.id)
notificationContent.sound = thread.notificationSound
.defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound)
.notificationSound(isQuiet: false)
// Badge Number

@ -70,89 +70,61 @@ public extension Identity {
}
static func userExists(_ db: Database? = nil) -> Bool {
let userExists: (Database) -> Bool = { db in
return (
(try? Identity.fetchOne(db, id: .x25519PublicKey)) != nil &&
(try? Identity.fetchOne(db, id: .x25519PrivateKey)) != nil
)
}
if let db: Database = db {
return userExists(db)
}
return GRDBStorage.shared
.read { db -> Bool in userExists(db) }
.defaulting(to: false)
return (fetchUserKeyPair(db) != nil)
}
static func fetchUserPublicKey(_ db: Database? = nil) -> Data? {
if let db: Database = db {
return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data
guard let db: Database = db else {
return GRDBStorage.shared.read { db in fetchUserPublicKey(db) }
}
return GRDBStorage.shared.read { db -> Data? in
try Identity.fetchOne(db, id: .x25519PublicKey)?.data
}
return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data
}
static func fetchUserPrivateKey(_ db: Database? = nil) -> Data? {
if let db: Database = db {
return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data
guard let db: Database = db else {
return GRDBStorage.shared.read { db in fetchUserPrivateKey(db) }
}
return GRDBStorage.shared.read { db -> Data? in
try Identity.fetchOne(db, id: .x25519PrivateKey)?.data
}
return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data
}
static func fetchUserKeyPair(_ db: Database? = nil) -> Box.KeyPair? {
let fetchKeys: (Database) -> Box.KeyPair? = { db in
guard
let publicKey: Identity = try? Identity.fetchOne(db, id: .x25519PublicKey),
let privateKey: Identity = try? Identity.fetchOne(db, id: .x25519PrivateKey)
else {
return nil
}
return Box.KeyPair(
publicKey: publicKey.data.bytes,
secretKey: privateKey.data.bytes
)
}
if let db: Database = db {
return fetchKeys(db)
guard let db: Database = db else {
return GRDBStorage.shared.read { db in fetchUserKeyPair(db) }
}
guard
let publicKey: Data = fetchUserPublicKey(db),
let privateKey: Data = fetchUserPrivateKey(db)
else { return nil }
return GRDBStorage.shared.read { db -> Box.KeyPair? in
return fetchKeys(db)
}
return Box.KeyPair(
publicKey: publicKey.bytes,
secretKey: privateKey.bytes
)
}
static func fetchUserEd25519KeyPair() -> Box.KeyPair? {
return GRDBStorage.shared.read { db -> Box.KeyPair? in
guard
let publicKey: Identity = try? Identity.fetchOne(db, id: .ed25519PublicKey),
let secretKey: Identity = try? Identity.fetchOne(db, id: .ed25519SecretKey)
else {
return nil
}
let publicKey: Data = try? Identity.fetchOne(db, id: .ed25519PublicKey)?.data,
let secretKey: Data = try? Identity.fetchOne(db, id: .ed25519SecretKey)?.data
else { return nil }
return Box.KeyPair(
publicKey: publicKey.data.bytes,
secretKey: secretKey.data.bytes
publicKey: publicKey.bytes,
secretKey: secretKey.bytes
)
}
}
static func fetchHexEncodedSeed() -> String? {
return GRDBStorage.shared.read { db in
guard let value: Identity = try? Identity.fetchOne(db, id: .seed) else {
guard let data: Data = try? Identity.fetchOne(db, id: .seed)?.data else {
return nil
}
return value.data.toHexString()
return data.toHexString()
}
}

@ -30,8 +30,14 @@ extension Setting {
self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue))
}
fileprivate func value<T>(as type: T.Type) -> T {
return value.withUnsafeBytes { $0.load(as: T.self) }
fileprivate func value<T>(as type: T.Type) -> T? {
// Note: The 'assumingMemoryBound' is essentially going to try to convert
// the memory into the provided type so can result in invalid data being
// returned if the type is incorrect. But it does seem safer than the 'load'
// method which crashed under certain circumstances (an `Int` value of 0)
return value.withUnsafeBytes {
$0.baseAddress?.assumingMemoryBound(to: T.self).pointee
}
}
}

@ -23,6 +23,12 @@ public extension Array {
return updatedArray
}
func removing(index: Int) -> [Element] {
var updatedArray: [Element] = self
updatedArray.remove(at: index)
return updatedArray
}
mutating func popFirst() -> Element? {
guard !self.isEmpty else { return nil }

@ -2,32 +2,6 @@
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
@objc
public class AnyLRUCache: NSObject {
private let backingCache: LRUCache<NSObject, NSObject>
@objc
public init(maxSize: Int) {
backingCache = LRUCache(maxSize: maxSize)
}
@objc
public func get(key: NSObject) -> NSObject? {
return self.backingCache.get(key: key)
}
@objc
public func set(key: NSObject, value: NSObject) {
self.backingCache.set(key: key, value: value)
}
@objc
public func clear() {
self.backingCache.clear()
}
}
// A simple LRU cache bounded by the number of entries.
public class LRUCache<KeyType: Hashable & Equatable, ValueType> {

@ -10,7 +10,6 @@
#import <SessionMessagingKit/OWSDisappearingMessagesJob.h>
#import <SessionMessagingKit/OWSOutgoingReceiptManager.h>
#import <SessionMessagingKit/OWSReadReceiptManager.h>
#import <SessionMessagingKit/OWSSounds.h>
#import <SessionMessagingKit/OWSStorage.h>
#import <SessionMessagingKit/SSKEnvironment.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
@ -59,14 +58,12 @@ NS_ASSUME_NONNULL_BEGIN
id<OWSTypingIndicators> typingIndicators = [[OWSTypingIndicatorsImpl alloc] init];
OWSAudioSession *audioSession = [OWSAudioSession new];
OWSSounds *sounds = [[OWSSounds alloc] initWithPrimaryStorage:primaryStorage];
id<OWSProximityMonitoringManager> proximityMonitoringManager = [OWSProximityMonitoringManagerImpl new];
OWSWindowManager *windowManager = [[OWSWindowManager alloc] initDefault];
[Environment setShared:[[Environment alloc] initWithAudioSession:audioSession
preferences:preferences
proximityMonitoringManager:proximityMonitoringManager
sounds:sounds
windowManager:windowManager]];
// TODO: Add this back

Loading…
Cancel
Save