From 3b06434d4f91e4cf73f3f553f9512b3b01c5230a Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 4 Oct 2018 16:42:05 -0400 Subject: [PATCH] Split out second web socket. --- Signal/src/AppDelegate.m | 4 +- Signal/src/Jobs/MessageFetcherJob.swift | 2 +- .../AdvancedSettingsTableViewController.m | 9 +- .../AppSettings/AppSettingsViewController.m | 10 +- .../SignalsNavigationController.m | 15 +- .../profiles/ProfileFetcherJob.swift | 18 +- .../src/Messages/OWSMessageSender.m | 5 +- .../src/Network/WebSockets/OWSWebSocket.h | 59 + .../src/Network/WebSockets/OWSWebSocket.m | 1116 +++++++++++++++++ .../src/Network/WebSockets/TSSocketManager.h | 36 +- .../src/Network/WebSockets/TSSocketManager.m | 1065 +--------------- 11 files changed, 1258 insertions(+), 1081 deletions(-) create mode 100644 SignalServiceKit/src/Network/WebSockets/OWSWebSocket.h create mode 100644 SignalServiceKit/src/Network/WebSockets/OWSWebSocket.m diff --git a/Signal/src/AppDelegate.m b/Signal/src/AppDelegate.m index e6bd22595..7376bee1b 100644 --- a/Signal/src/AppDelegate.m +++ b/Signal/src/AppDelegate.m @@ -595,7 +595,7 @@ static NSTimeInterval launchStartedAt; // Unregistered user should have no unread messages. e.g. if you delete your account. [SignalApp clearAllNotifications]; - [TSSocketManager requestSocketOpen]; + [TSSocketManager.shared requestSocketOpen]; UITapGestureRecognizer *gesture = [[UITapGestureRecognizer alloc] initWithTarget:[Pastelog class] action:@selector(submitLogs)]; @@ -609,7 +609,7 @@ static NSTimeInterval launchStartedAt; // At this point, potentially lengthy DB locking migrations could be running. // Avoid blocking app launch by putting all further possible DB access in async block dispatch_async(dispatch_get_main_queue(), ^{ - [TSSocketManager requestSocketOpen]; + [TSSocketManager.shared requestSocketOpen]; [Environment.shared.contactsManager fetchSystemContactsOnceIfAlreadyAuthorized]; // This will fetch new messages, if we're using domain fronting. [[PushManager sharedManager] applicationDidBecomeActive]; diff --git a/Signal/src/Jobs/MessageFetcherJob.swift b/Signal/src/Jobs/MessageFetcherJob.swift index 39457188b..e34c0ab9a 100644 --- a/Signal/src/Jobs/MessageFetcherJob.swift +++ b/Signal/src/Jobs/MessageFetcherJob.swift @@ -40,7 +40,7 @@ public class MessageFetcherJob: NSObject { guard signalService.isCensorshipCircumventionActive else { Logger.debug("delegating message fetching to SocketManager since we're using normal transport.") - TSSocketManager.requestSocketOpen() + TSSocketManager.shared.requestSocketOpen() return Promise(value: ()) } diff --git a/Signal/src/ViewControllers/AppSettings/AdvancedSettingsTableViewController.m b/Signal/src/ViewControllers/AppSettings/AdvancedSettingsTableViewController.m index 261ca06ca..688d39390 100644 --- a/Signal/src/ViewControllers/AppSettings/AdvancedSettingsTableViewController.m +++ b/Signal/src/ViewControllers/AppSettings/AdvancedSettingsTableViewController.m @@ -45,7 +45,7 @@ NS_ASSUME_NONNULL_BEGIN { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(socketStateDidChange) - name:kNSNotification_SocketManagerStateDidChange + name:kNSNotification_OWSWebSocketStateDidChange object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reachabilityChanged) @@ -128,12 +128,13 @@ NS_ASSUME_NONNULL_BEGIN OWSTableSection *censorshipSection = [OWSTableSection new]; censorshipSection.headerTitle = NSLocalizedString(@"SETTINGS_ADVANCED_CENSORSHIP_CIRCUMVENTION_HEADER", @"Table header for the 'censorship circumvention' section."); + BOOL isAnySocketOpen = TSSocketManager.shared.highestSocketState == OWSWebSocketStateOpen; if (OWSSignalService.sharedInstance.hasCensoredPhoneNumber) { censorshipSection.footerTitle = NSLocalizedString(@"SETTINGS_ADVANCED_CENSORSHIP_CIRCUMVENTION_FOOTER_AUTO_ENABLED", @"Table footer for the 'censorship circumvention' section shown when censorship circumvention has been " @"auto-enabled based on local phone number."); - } else if (TSSocketManager.shared.state == SocketManagerStateOpen) { + } else if (isAnySocketOpen) { censorshipSection.footerTitle = NSLocalizedString(@"SETTINGS_ADVANCED_CENSORSHIP_CIRCUMVENTION_FOOTER_WEBSOCKET_CONNECTED", @"Table footer for the 'censorship circumvention' section shown when the app is connected to the " @@ -162,8 +163,8 @@ NS_ASSUME_NONNULL_BEGIN // internet connection. BOOL isManualCensorshipCircumventionOnEnabled = (OWSSignalService.sharedInstance.isCensorshipCircumventionManuallyActivated - || (!OWSSignalService.sharedInstance.hasCensoredPhoneNumber - && TSSocketManager.shared.state != SocketManagerStateOpen && weakSelf.reachability.isReachable)); + || (!OWSSignalService.sharedInstance.hasCensoredPhoneNumber && !isAnySocketOpen + && weakSelf.reachability.isReachable)); BOOL isCensorshipCircumventionOn = NO; if (OWSSignalService.sharedInstance.hasCensoredPhoneNumber) { isCensorshipCircumventionOn = YES; diff --git a/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.m b/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.m index b44cf4b72..69de9483c 100644 --- a/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.m +++ b/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.m @@ -148,16 +148,16 @@ @"Error indicating that this device is no longer registered."); accessoryLabel.textColor = [UIColor ows_redColor]; } else { - switch (TSSocketManager.shared.state) { - case SocketManagerStateClosed: + switch (TSSocketManager.shared.highestSocketState) { + case OWSWebSocketStateClosed: accessoryLabel.text = NSLocalizedString(@"NETWORK_STATUS_OFFLINE", @""); accessoryLabel.textColor = [UIColor ows_redColor]; break; - case SocketManagerStateConnecting: + case OWSWebSocketStateConnecting: accessoryLabel.text = NSLocalizedString(@"NETWORK_STATUS_CONNECTING", @""); accessoryLabel.textColor = [UIColor ows_yellowColor]; break; - case SocketManagerStateOpen: + case OWSWebSocketStateOpen: accessoryLabel.text = NSLocalizedString(@"NETWORK_STATUS_CONNECTED", @""); accessoryLabel.textColor = [UIColor ows_greenColor]; break; @@ -473,7 +473,7 @@ { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(socketStateDidChange) - name:kNSNotification_SocketManagerStateDidChange + name:kNSNotification_OWSWebSocketStateDidChange object:nil]; } diff --git a/Signal/src/ViewControllers/SignalsNavigationController.m b/Signal/src/ViewControllers/SignalsNavigationController.m index 27e8848f5..00d050f49 100644 --- a/Signal/src/ViewControllers/SignalsNavigationController.m +++ b/Signal/src/ViewControllers/SignalsNavigationController.m @@ -54,8 +54,8 @@ static double const STALLED_PROGRESS = 0.9; - (void)initializeObserver { [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(socketManagerStateDidChange) - name:kNSNotification_SocketManagerStateDidChange + selector:@selector(OWSWebSocketStateDidChange) + name:kNSNotification_OWSWebSocketStateDidChange object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(isCensorshipCircumventionActiveDidChange:) @@ -70,7 +70,8 @@ static double const STALLED_PROGRESS = 0.9; [self updateSocketStatusView]; } -- (void)socketManagerStateDidChange { +- (void)OWSWebSocketStateDidChange +{ OWSAssertIsOnMainThread(); [self updateSocketStatusView]; @@ -86,8 +87,8 @@ static double const STALLED_PROGRESS = 0.9; return; } - switch (TSSocketManager.shared.state) { - case SocketManagerStateClosed: + switch (TSSocketManager.shared.highestSocketState) { + case OWSWebSocketStateClosed: if (_socketStatusView == nil) { [self initializeSocketStatusBar]; [_updateStatusTimer invalidate]; @@ -101,10 +102,10 @@ static double const STALLED_PROGRESS = 0.9; [_updateStatusTimer invalidate]; } break; - case SocketManagerStateConnecting: + case OWSWebSocketStateConnecting: // Do nothing. break; - case SocketManagerStateOpen: + case OWSWebSocketStateOpen: [_updateStatusTimer invalidate]; [_socketStatusView removeFromSuperview]; _socketStatusView = nil; diff --git a/SignalMessaging/profiles/ProfileFetcherJob.swift b/SignalMessaging/profiles/ProfileFetcherJob.swift index 60768057e..f5c30ded9 100644 --- a/SignalMessaging/profiles/ProfileFetcherJob.swift +++ b/SignalMessaging/profiles/ProfileFetcherJob.swift @@ -37,7 +37,7 @@ public class ProfileFetcherJob: NSObject { } private var socketManager: TSSocketManager { - return TSSocketManager.shared() + return TSSocketManager.shared } private var primaryStorage: OWSPrimaryStorage { @@ -48,6 +48,14 @@ public class ProfileFetcherJob: NSObject { return SSKEnvironment.shared.udManager } + private var profileManager: OWSProfileManager { + return OWSProfileManager.shared() + } + + private var identityManager: OWSIdentityManager { + return SSKEnvironment.shared.identityManager + } + // MARK: - public func run(recipientIds: [String]) { @@ -126,8 +134,10 @@ public class ProfileFetcherJob: NSObject { let (promise, fulfill, reject) = Promise.pending() - if TSSocketManager.canMakeRequests() { + // TODO: Use UD socket for some profile gets. + if socketManager.canMakeRequests(of: .default) { self.socketManager.make(request, + webSocketType: .default, success: { (responseObject: Any?) -> Void in do { let profile = try SignalServiceProfile(recipientId: recipientId, responseObject: responseObject) @@ -165,7 +175,7 @@ public class ProfileFetcherJob: NSObject { private func updateProfile(signalServiceProfile: SignalServiceProfile) { verifyIdentityUpToDateAsync(recipientId: signalServiceProfile.recipientId, latestIdentityKey: signalServiceProfile.identityKey) - OWSProfileManager.shared().updateProfile(forRecipientId: signalServiceProfile.recipientId, + profileManager.updateProfile(forRecipientId: signalServiceProfile.recipientId, profileNameEncrypted: signalServiceProfile.profileNameEncrypted, avatarUrlPath: signalServiceProfile.avatarUrlPath) @@ -179,7 +189,7 @@ public class ProfileFetcherJob: NSObject { private func verifyIdentityUpToDateAsync(recipientId: String, latestIdentityKey: Data) { primaryStorage.newDatabaseConnection().asyncReadWrite { (transaction) in - if OWSIdentityManager.shared().saveRemoteIdentity(latestIdentityKey, recipientId: recipientId, protocolContext: transaction) { + if self.identityManager.saveRemoteIdentity(latestIdentityKey, recipientId: recipientId, protocolContext: transaction) { Logger.info("updated identity key with fetched profile for recipient: \(recipientId)") self.primaryStorage.archiveAllSessions(forContact: recipientId, protocolContext: transaction) } else { diff --git a/SignalServiceKit/src/Messages/OWSMessageSender.m b/SignalServiceKit/src/Messages/OWSMessageSender.m index 67b487e30..754658d02 100644 --- a/SignalServiceKit/src/Messages/OWSMessageSender.m +++ b/SignalServiceKit/src/Messages/OWSMessageSender.m @@ -1005,8 +1005,11 @@ NSString *const OWSMessageSenderRateLimitedException = @"RateLimitedException"; } // TODO: UD sends over websocket. - if (!messageSend.hasWebsocketSendFailed && TSSocketManager.canMakeRequests && !messageSend.isUDSend) { + OWSWebSocketType webSocketType = (isUDSend ? OWSWebSocketTypeUD : OWSWebSocketTypeDefault); + BOOL canMakeWebsocketRequests = [TSSocketManager.shared canMakeRequestsOfType:webSocketType]; + if (!messageSend.hasWebsocketSendFailed && canMakeWebsocketRequests && !messageSend..isUDSend) { [TSSocketManager.shared makeRequest:request + webSocketType:webSocketType success:^(id _Nullable responseObject) { [self messageSendDidSucceed:messageSend deviceMessages:deviceMessages]; } diff --git a/SignalServiceKit/src/Network/WebSockets/OWSWebSocket.h b/SignalServiceKit/src/Network/WebSockets/OWSWebSocket.h new file mode 100644 index 000000000..bd44ffe9c --- /dev/null +++ b/SignalServiceKit/src/Network/WebSockets/OWSWebSocket.h @@ -0,0 +1,59 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +NS_ASSUME_NONNULL_BEGIN + +static void *OWSWebSocketStateObservationContext = &OWSWebSocketStateObservationContext; + +extern NSString *const kNSNotification_OWSWebSocketStateDidChange; + +typedef NS_ENUM(NSUInteger, OWSWebSocketType) { + OWSWebSocketTypeDefault, + OWSWebSocketTypeUD, +}; + +typedef NS_ENUM(NSUInteger, OWSWebSocketState) { + OWSWebSocketStateClosed, + OWSWebSocketStateConnecting, + OWSWebSocketStateOpen, +}; + +typedef void (^TSSocketMessageSuccess)(id _Nullable responseObject); +// statusCode is zero by default, if request never made or failed. +typedef void (^TSSocketMessageFailure)(NSInteger statusCode, NSData *_Nullable responseData, NSError *error); + +@class TSRequest; + +@interface OWSWebSocket : NSObject + +@property (nonatomic, readonly) OWSWebSocketState state; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithWebSocketType:(OWSWebSocketType)webSocketType NS_DESIGNATED_INITIALIZER; + +// If the app is in the foreground, we'll try to open the socket unless it's already +// open or connecting. +// +// If the app is in the background, we'll try to open the socket unless it's already +// open or connecting _and_ keep it open for at least N seconds. +// If the app is in the background and the socket is already open or connecting this +// might prolong how long we keep the socket open. +// +// This method can be called from any thread. +- (void)requestSocketOpen; + +// This can be used to force the socket to close and re-open, if it is open. +- (void)cycleSocket; + +#pragma mark - Message Sending + +@property (atomic, readonly) BOOL canMakeRequests; + +- (void)makeRequest:(TSRequest *)request + success:(TSSocketMessageSuccess)success + failure:(TSSocketMessageFailure)failure; + +@end + +NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Network/WebSockets/OWSWebSocket.m b/SignalServiceKit/src/Network/WebSockets/OWSWebSocket.m new file mode 100644 index 000000000..cef008a4c --- /dev/null +++ b/SignalServiceKit/src/Network/WebSockets/OWSWebSocket.m @@ -0,0 +1,1116 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +#import "OWSWebSocket.h" +#import "AppContext.h" +#import "AppReadiness.h" +#import "NSNotificationCenter+OWS.h" +#import "NSTimer+OWS.h" +#import "NotificationsProtocol.h" +#import "OWSBackgroundTask.h" +#import "OWSError.h" +#import "OWSMessageManager.h" +#import "OWSMessageReceiver.h" +#import "OWSPrimaryStorage.h" +#import "OWSSignalService.h" +#import "OWSWebsocketSecurityPolicy.h" +#import "SSKEnvironment.h" +#import "TSAccountManager.h" +#import "TSConstants.h" +#import "TSErrorMessage.h" +#import "TSRequest.h" +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +static const CGFloat kSocketHeartbeatPeriodSeconds = 30.f; +static const CGFloat kSocketReconnectDelaySeconds = 5.f; + +// If the app is in the background, it should keep the +// websocket open if: +// +// a) It has received a notification in the last 25 seconds. +static const CGFloat kBackgroundOpenSocketDurationSeconds = 25.f; +// b) It has received a message over the socket in the last 15 seconds. +static const CGFloat kBackgroundKeepSocketAliveDurationSeconds = 15.f; +// c) It is in the process of making a request. +static const CGFloat kMakeRequestKeepSocketAliveDurationSeconds = 30.f; + +NSString *const kNSNotification_OWSWebSocketStateDidChange = @"kNSNotification_OWSWebSocketStateDidChange"; + +@interface TSSocketMessage : NSObject + +@property (nonatomic, readonly) UInt64 requestId; +@property (nonatomic, nullable) TSSocketMessageSuccess success; +@property (nonatomic, nullable) TSSocketMessageFailure failure; +@property (nonatomic) BOOL hasCompleted; +@property (nonatomic, readonly) OWSBackgroundTask *backgroundTask; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +#pragma mark - + +@implementation TSSocketMessage + +- (instancetype)initWithRequestId:(UInt64)requestId + success:(TSSocketMessageSuccess)success + failure:(TSSocketMessageFailure)failure +{ + if (self = [super init]) { + OWSAssertDebug(success); + OWSAssertDebug(failure); + + _requestId = requestId; + _success = success; + _failure = failure; + _backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; + } + + return self; +} + +- (void)didSucceedWithResponseObject:(id _Nullable)responseObject +{ + @synchronized(self) { + if (self.hasCompleted) { + return; + } + self.hasCompleted = YES; + } + + OWSAssertDebug(self.success); + OWSAssertDebug(self.failure); + + TSSocketMessageSuccess success = self.success; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + success(responseObject); + }); + + self.success = nil; + self.failure = nil; +} + +- (void)timeoutIfNecessary +{ + NSError *error = OWSErrorWithCodeDescription(OWSErrorCodeMessageRequestFailed, + NSLocalizedString( + @"ERROR_DESCRIPTION_REQUEST_TIMED_OUT", @"Error indicating that a socket request timed out.")); + + [self didFailWithStatusCode:0 responseData:nil error:error]; +} + +- (void)didFailBeforeSending +{ + NSError *error = OWSErrorWithCodeDescription(OWSErrorCodeMessageRequestFailed, + NSLocalizedString(@"ERROR_DESCRIPTION_REQUEST_FAILED", @"Error indicating that a socket request failed.")); + + [self didFailWithStatusCode:0 responseData:nil error:error]; +} + +- (void)didFailWithStatusCode:(NSInteger)statusCode responseData:(nullable NSData *)responseData error:(NSError *)error +{ + OWSAssertDebug(error); + + @synchronized(self) { + if (self.hasCompleted) { + return; + } + self.hasCompleted = YES; + } + + OWSLogError(@"didFailWithStatusCode: %zd, %@", statusCode, error); + + OWSAssertDebug(self.success); + OWSAssertDebug(self.failure); + + TSSocketMessageFailure failure = self.failure; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + failure(statusCode, responseData, error); + }); + + self.success = nil; + self.failure = nil; +} + +@end + +#pragma mark - + +// OWSWebSocket's properties should only be accessed from the main thread. +@interface OWSWebSocket () + +@property (nonatomic) OWSWebSocketType webSocketType; + +// This class has a few "tiers" of state. +// +// The first tier is the actual websocket and the timers used +// to keep it alive and connected. +@property (nonatomic, nullable) SRWebSocket *websocket; +@property (nonatomic, nullable) NSTimer *heartbeatTimer; +@property (nonatomic, nullable) NSTimer *reconnectTimer; + +#pragma mark - + +// The second tier is the state property. We initiate changes +// to the websocket by changing this property's value, and delegate +// events from the websocket also update this value as the websocket's +// state changes. +// +// Due to concurrency, this property can fall out of sync with the +// websocket's actual state, so we're defensive and distrustful of +// this property. +// +// We only ever access this state on the main thread. +@property (nonatomic) OWSWebSocketState state; + +#pragma mark - + +// The third tier is the state that is used to determine what the +// "desired" state of the websocket is. +// +// If we're keeping the socket open in the background, all three of these +// properties will be set. Otherwise (if the app is active or if we're not +// trying to keep the socket open), all three should be clear. +// +// This represents how long we're trying to keep the socket open. +@property (nonatomic, nullable) NSDate *backgroundKeepAliveUntilDate; +// This timer is used to check periodically whether we should +// close the socket. +@property (nonatomic, nullable) NSTimer *backgroundKeepAliveTimer; +// This is used to manage the iOS "background task" used to +// keep the app alive in the background. +@property (nonatomic, nullable) OWSBackgroundTask *backgroundTask; + +// We cache this value instead of consulting [UIApplication sharedApplication].applicationState, +// because UIKit only provides a "will resign active" notification, not a "did resign active" +// notification. +@property (nonatomic) BOOL appIsActive; + +@property (nonatomic) BOOL hasObservedNotifications; + +// This property should only be accessed while synchronized on the socket manager. +@property (nonatomic, readonly) NSMutableDictionary *socketMessageMap; + +@property (atomic) BOOL canMakeRequests; + +@end + +#pragma mark - + +@implementation OWSWebSocket + +- (instancetype)initWithWebSocketType:(OWSWebSocketType)webSocketType +{ + self = [super init]; + + if (!self) { + return self; + } + + OWSAssertIsOnMainThread(); + + _webSocketType = webSocketType; + _state = OWSWebSocketStateClosed; + _socketMessageMap = [NSMutableDictionary new]; + + return self; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +#pragma mark - Dependencies + +- (OWSSignalService *)signalService +{ + return [OWSSignalService sharedInstance]; +} + +- (OWSMessageReceiver *)messageReceiver +{ + return SSKEnvironment.shared.messageReceiver; +} + +- (TSAccountManager *)tsAccountManager +{ + return TSAccountManager.sharedInstance; +} + +- (OutageDetection *)outageDetection +{ + return OutageDetection.sharedManager; +} + +- (OWSPrimaryStorage *)primaryStorage +{ + return SSKEnvironment.shared.primaryStorage; +} + +- (id)notificationsManager +{ + return SSKEnvironment.shared.notificationsManager; +} + +- (OWSWebsocketSecurityPolicy *)websocketSecurityPolicy +{ + return OWSWebsocketSecurityPolicy.sharedPolicy; +} + +#pragma mark - + +// We want to observe these notifications lazily to avoid accessing +// the data store in [application: didFinishLaunchingWithOptions:]. +- (void)observeNotificationsIfNecessary +{ + if (self.hasObservedNotifications) { + return; + } + self.hasObservedNotifications = YES; + + self.appIsActive = CurrentAppContext().isMainAppAndActive; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationDidBecomeActive:) + name:OWSApplicationDidBecomeActiveNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationWillResignActive:) + name:OWSApplicationWillResignActiveNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(registrationStateDidChange:) + name:RegistrationStateDidChangeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(isCensorshipCircumventionActiveDidChange:) + name:kNSNotificationName_IsCensorshipCircumventionActiveDidChange + object:nil]; +} + +#pragma mark - Manage Socket + +- (void)ensureWebsocketIsOpen +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(!self.signalService.isCensorshipCircumventionActive); + + // Try to reuse the existing socket (if any) if it is in a valid state. + if (self.websocket) { + switch ([self.websocket readyState]) { + case SR_OPEN: + self.state = OWSWebSocketStateOpen; + return; + case SR_CONNECTING: + OWSLogVerbose(@"WebSocket is already connecting"); + self.state = OWSWebSocketStateConnecting; + return; + default: + break; + } + } + + OWSLogWarn(@"Creating new websocket"); + + // If socket is not already open or connecting, connect now. + // + // First we need to close the existing websocket, if any. + // The websocket delegate methods are invoked _after_ the websocket + // state changes, so we may be just learning about a socket failure + // or close event now. + self.state = OWSWebSocketStateClosed; + // Now open a new socket. + self.state = OWSWebSocketStateConnecting; +} + +- (NSString *)stringFromOWSWebSocketState:(OWSWebSocketState)state +{ + switch (state) { + case OWSWebSocketStateClosed: + return @"Closed"; + case OWSWebSocketStateOpen: + return @"Open"; + case OWSWebSocketStateConnecting: + return @"Connecting"; + } +} + +// We need to keep websocket state and class state tightly aligned. +// +// Sometimes we'll need to update class state to reflect changes +// in socket state; sometimes we'll need to update socket state +// and class state to reflect changes in app state. +// +// We learn about changes to socket state through websocket +// delegate methods like [webSocketDidOpen:], [didFailWithError:...] +// and [didCloseWithCode:...]. These delegate methods are sometimes +// invoked _after_ web socket state changes, so we sometimes learn +// about changes to socket state in [ensureWebsocket]. Put another way, +// it's not safe to assume we'll learn of changes to websocket state +// in the websocket delegate methods. +// +// Therefore, we use the [setState:] setter to ensure alignment between +// websocket state and class state. +- (void)setState:(OWSWebSocketState)state +{ + OWSAssertIsOnMainThread(); + + // If this state update is redundant, verify that + // class state and socket state are aligned. + // + // Note: it's not safe to check the socket's readyState here as + // it may have been just updated on another thread. If so, + // we'll learn of that state change soon. + if (_state == state) { + switch (state) { + case OWSWebSocketStateClosed: + OWSAssertDebug(!self.websocket); + break; + case OWSWebSocketStateOpen: + OWSAssertDebug(self.websocket); + break; + case OWSWebSocketStateConnecting: + OWSAssertDebug(self.websocket); + break; + } + return; + } + + OWSLogWarn( + @"Socket state: %@ -> %@", [self stringFromOWSWebSocketState:_state], [self stringFromOWSWebSocketState:state]); + + // If this state update is _not_ redundant, + // update class state to reflect the new state. + switch (state) { + case OWSWebSocketStateClosed: { + [self resetSocket]; + break; + } + case OWSWebSocketStateOpen: { + OWSAssertDebug(self.state == OWSWebSocketStateConnecting); + + self.heartbeatTimer = [NSTimer timerWithTimeInterval:kSocketHeartbeatPeriodSeconds + target:self + selector:@selector(webSocketHeartBeat) + userInfo:nil + repeats:YES]; + + // Additionally, we want the ping timer to work in the background too. + [[NSRunLoop mainRunLoop] addTimer:self.heartbeatTimer forMode:NSDefaultRunLoopMode]; + + // If the socket is open, we don't need to worry about reconnecting. + [self clearReconnect]; + break; + } + case OWSWebSocketStateConnecting: { + // Discard the old socket which is already closed or is closing. + [self resetSocket]; + + // Create a new web socket. + NSString *webSocketConnect = + [textSecureWebSocketAPI stringByAppendingString:[self webSocketAuthenticationString]]; + NSURL *webSocketConnectURL = [NSURL URLWithString:webSocketConnect]; + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:webSocketConnectURL]; + + SRWebSocket *socket = + [[SRWebSocket alloc] initWithURLRequest:request securityPolicy:self.websocketSecurityPolicy]; + socket.delegate = self; + + [self setWebsocket:socket]; + + // [SRWebSocket open] could hypothetically call a delegate method (e.g. if + // the socket failed immediately for some reason), so we update the state + // _before_ calling it, not after. + _state = state; + self.canMakeRequests = state == OWSWebSocketStateOpen; + [socket open]; + [self failAllPendingSocketMessagesIfNecessary]; + return; + } + } + + _state = state; + self.canMakeRequests = state == OWSWebSocketStateOpen; + + [self failAllPendingSocketMessagesIfNecessary]; + [self notifyStatusChange]; +} + +- (void)notifyStatusChange +{ + [[NSNotificationCenter defaultCenter] postNotificationNameAsync:kNSNotification_OWSWebSocketStateDidChange + object:nil + userInfo:nil]; +} + +#pragma mark - + +- (void)resetSocket +{ + OWSAssertIsOnMainThread(); + + self.websocket.delegate = nil; + [self.websocket close]; + self.websocket = nil; + [self.heartbeatTimer invalidate]; + self.heartbeatTimer = nil; +} + +- (void)closeWebSocket +{ + OWSAssertIsOnMainThread(); + + if (self.websocket) { + OWSLogWarn(@"closeWebSocket."); + } + + self.state = OWSWebSocketStateClosed; +} + +#pragma mark - Message Sending + +- (void)makeRequest:(TSRequest *)request success:(TSSocketMessageSuccess)success failure:(TSSocketMessageFailure)failure +{ + OWSAssertDebug(request); + OWSAssertDebug(request.HTTPMethod.length > 0); + OWSAssertDebug(success); + OWSAssertDebug(failure); + + TSSocketMessage *socketMessage = + [[TSSocketMessage alloc] initWithRequestId:[Cryptography randomUInt64] success:success failure:failure]; + + @synchronized(self) { + self.socketMessageMap[@(socketMessage.requestId)] = socketMessage; + } + + NSURL *requestUrl = request.URL; + NSString *requestPath = [@"/" stringByAppendingString:requestUrl.path]; + + NSData *_Nullable jsonData = nil; + if (request.parameters) { + NSError *error; + jsonData = + [NSJSONSerialization dataWithJSONObject:request.parameters options:(NSJSONWritingOptions)0 error:&error]; + if (!jsonData || error) { + OWSFailDebug(@"could not serialize request JSON: %@", error); + [socketMessage didFailBeforeSending]; + return; + } + } + + WebSocketProtoWebSocketRequestMessageBuilder *requestBuilder = + [WebSocketProtoWebSocketRequestMessage builderWithVerb:request.HTTPMethod + path:requestPath + requestID:socketMessage.requestId]; + if (jsonData) { + // TODO: Do we need body & headers for requests with no parameters? + [requestBuilder setBody:jsonData]; + [requestBuilder setHeaders:@[ + @"content-type:application/json", + ]]; + } + + NSError *error; + WebSocketProtoWebSocketRequestMessage *_Nullable requestProto = [requestBuilder buildAndReturnError:&error]; + if (!requestProto || error) { + OWSFailDebug(@"could not build proto: %@", error); + return; + } + + WebSocketProtoWebSocketMessageBuilder *messageBuilder = + [WebSocketProtoWebSocketMessage builderWithType:WebSocketProtoWebSocketMessageTypeRequest]; + [messageBuilder setRequest:requestProto]; + + NSData *_Nullable messageData = [messageBuilder buildSerializedDataAndReturnError:&error]; + if (!messageData || error) { + OWSFailDebug(@"could not serialize proto: %@.", error); + [socketMessage didFailBeforeSending]; + return; + } + + if (!self.canMakeRequests) { + OWSLogError(@"makeRequest: socket not open."); + [socketMessage didFailBeforeSending]; + return; + } + + BOOL wasScheduled = [self.websocket sendDataNoCopy:messageData error:&error]; + if (!wasScheduled || error) { + OWSFailDebug(@"could not send socket request: %@", error); + [socketMessage didFailBeforeSending]; + return; + } + OWSLogVerbose(@"message scheduled: %llu, %@, %@, %zd.", + socketMessage.requestId, + request.HTTPMethod, + requestPath, + jsonData.length); + + const int64_t kSocketTimeoutSeconds = 10; + __weak TSSocketMessage *weakSocketMessage = socketMessage; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kSocketTimeoutSeconds * NSEC_PER_SEC), + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), + ^{ + [weakSocketMessage timeoutIfNecessary]; + }); +} + +- (void)processWebSocketResponseMessage:(WebSocketProtoWebSocketResponseMessage *)message +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(message); + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self processWebSocketResponseMessageAsync:message]; + }); +} + +- (void)processWebSocketResponseMessageAsync:(WebSocketProtoWebSocketResponseMessage *)message +{ + OWSAssertDebug(message); + + OWSLogInfo(@"received WebSocket response."); + + DispatchMainThreadSafe(^{ + [self requestSocketAliveForAtLeastSeconds:kMakeRequestKeepSocketAliveDurationSeconds]; + }); + + UInt64 requestId = message.requestID; + UInt32 responseStatus = message.status; + NSString *_Nullable responseMessage; + if (message.hasMessage) { + responseMessage = message.message; + } + NSData *_Nullable responseData; + if (message.hasBody) { + responseData = message.body; + } + + BOOL hasValidResponse = YES; + id responseObject = responseData; + if (responseData) { + NSError *error; + id _Nullable responseJson = + [NSJSONSerialization JSONObjectWithData:responseData options:(NSJSONReadingOptions)0 error:&error]; + if (!responseJson || error) { + OWSFailDebug(@"could not parse WebSocket response JSON: %@.", error); + hasValidResponse = NO; + } else { + responseObject = responseJson; + } + } + + TSSocketMessage *_Nullable socketMessage; + @synchronized(self) { + socketMessage = self.socketMessageMap[@(requestId)]; + [self.socketMessageMap removeObjectForKey:@(requestId)]; + } + + if (!socketMessage) { + OWSLogError(@"received response to unknown request."); + } else { + BOOL hasSuccessStatus = 200 <= responseStatus && responseStatus <= 299; + BOOL didSucceed = hasSuccessStatus && hasValidResponse; + if (didSucceed) { + [self.tsAccountManager setIsDeregistered:NO]; + + [socketMessage didSucceedWithResponseObject:responseObject]; + } else { + if (responseStatus == 403) { + // This should be redundant with our check for the socket + // failing due to 403, but let's be thorough. + [self.tsAccountManager setIsDeregistered:YES]; + } + + NSError *error = OWSErrorWithCodeDescription(OWSErrorCodeMessageResponseFailed, + NSLocalizedString( + @"ERROR_DESCRIPTION_RESPONSE_FAILED", @"Error indicating that a socket response failed.")); + [socketMessage didFailWithStatusCode:(NSInteger)responseStatus responseData:responseData error:error]; + } + } +} + +- (void)failAllPendingSocketMessagesIfNecessary +{ + if (!self.canMakeRequests) { + [self failAllPendingSocketMessages]; + } +} + +- (void)failAllPendingSocketMessages +{ + NSArray *socketMessages; + @synchronized(self) { + socketMessages = self.socketMessageMap.allValues; + [self.socketMessageMap removeAllObjects]; + } + + OWSLogInfo(@"failAllPendingSocketMessages: %zd.", socketMessages.count); + + for (TSSocketMessage *socketMessage in socketMessages) { + [socketMessage didFailBeforeSending]; + } +} + +#pragma mark - Delegate methods + +- (void)webSocketDidOpen:(SRWebSocket *)webSocket +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(webSocket); + if (webSocket != self.websocket) { + // Ignore events from obsolete web sockets. + return; + } + + self.state = OWSWebSocketStateOpen; + + // If socket opens, we know we're not de-registered. + [self.tsAccountManager setIsDeregistered:NO]; + + [self.outageDetection reportConnectionSuccess]; +} + +- (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(webSocket); + if (webSocket != self.websocket) { + // Ignore events from obsolete web sockets. + return; + } + + OWSLogError(@"Websocket did fail with error: %@", error); + + if ([error.domain isEqualToString:SRWebSocketErrorDomain] && error.code == 2132) { + NSNumber *_Nullable statusCode = error.userInfo[SRHTTPResponseErrorKey]; + if (statusCode.unsignedIntegerValue == 403) { + [self.tsAccountManager setIsDeregistered:YES]; + } + } + + [self handleSocketFailure]; +} + +- (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(NSData *)data +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(webSocket); + + if (webSocket != self.websocket) { + // Ignore events from obsolete web sockets. + return; + } + + // If we receive a response, we know we're not de-registered. + [self.tsAccountManager setIsDeregistered:NO]; + + NSError *error; + WebSocketProtoWebSocketMessage *_Nullable wsMessage = [WebSocketProtoWebSocketMessage parseData:data error:&error]; + if (!wsMessage || error) { + OWSFailDebug(@"could not parse proto: %@", error); + return; + } + + if (wsMessage.type == WebSocketProtoWebSocketMessageTypeRequest) { + [self processWebSocketRequestMessage:wsMessage.request]; + } else if (wsMessage.type == WebSocketProtoWebSocketMessageTypeResponse) { + [self processWebSocketResponseMessage:wsMessage.response]; + } else { + OWSLogWarn(@"webSocket:didReceiveMessage: unknown."); + } +} + +- (void)processWebSocketRequestMessage:(WebSocketProtoWebSocketRequestMessage *)message +{ + OWSAssertIsOnMainThread(); + + OWSLogInfo(@"Got message with verb: %@ and path: %@", message.verb, message.path); + + // If we receive a message over the socket while the app is in the background, + // prolong how long the socket stays open. + [self requestSocketAliveForAtLeastSeconds:kBackgroundKeepSocketAliveDurationSeconds]; + + if ([message.path isEqualToString:@"/api/v1/message"] && [message.verb isEqualToString:@"PUT"]) { + + __block OWSBackgroundTask *_Nullable backgroundTask = + [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + BOOL success = NO; + @try { + NSData *_Nullable decryptedPayload = + [Cryptography decryptAppleMessagePayload:message.body + withSignalingKey:TSAccountManager.signalingKey]; + + if (!decryptedPayload) { + OWSLogWarn(@"Failed to decrypt incoming payload or bad HMAC"); + } else { + [self.messageReceiver handleReceivedEnvelopeData:decryptedPayload]; + success = YES; + } + } @catch (NSException *exception) { + OWSFailDebug(@"Received an invalid envelope: %@", exception.debugDescription); + // TODO: Add analytics. + } + + if (!success) { + [[self.primaryStorage newDatabaseConnection] + readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + TSErrorMessage *errorMessage = [TSErrorMessage corruptedMessageInUnknownThread]; + [self.notificationsManager notifyUserForThreadlessErrorMessage:errorMessage + transaction:transaction]; + }]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [self sendWebSocketMessageAcknowledgement:message]; + OWSAssertDebug(backgroundTask); + backgroundTask = nil; + }); + }); + } else if ([message.path isEqualToString:@"/api/v1/queue/empty"]) { + // Queue is drained. + + [self sendWebSocketMessageAcknowledgement:message]; + } else { + OWSLogWarn(@"Unsupported WebSocket Request"); + + [self sendWebSocketMessageAcknowledgement:message]; + } +} + +- (void)sendWebSocketMessageAcknowledgement:(WebSocketProtoWebSocketRequestMessage *)request +{ + OWSAssertIsOnMainThread(); + + NSError *error; + + WebSocketProtoWebSocketResponseMessageBuilder *responseBuilder = + [WebSocketProtoWebSocketResponseMessage builderWithRequestID:request.requestID status:200]; + [responseBuilder setMessage:@"OK"]; + WebSocketProtoWebSocketResponseMessage *_Nullable response = [responseBuilder buildAndReturnError:&error]; + if (!response || error) { + OWSFailDebug(@"could not build proto: %@", error); + return; + } + + WebSocketProtoWebSocketMessageBuilder *messageBuilder = + [WebSocketProtoWebSocketMessage builderWithType:WebSocketProtoWebSocketMessageTypeResponse]; + [messageBuilder setResponse:response]; + + NSData *_Nullable messageData = [messageBuilder buildSerializedDataAndReturnError:&error]; + if (!messageData || error) { + OWSFailDebug(@"could not serialize proto: %@", error); + return; + } + + [self.websocket sendDataNoCopy:messageData error:&error]; + if (error) { + OWSLogWarn(@"Error while trying to write on websocket %@", error); + [self handleSocketFailure]; + } +} + +- (void)cycleSocket +{ + OWSAssertIsOnMainThread(); + + [self closeWebSocket]; + + [self applyDesiredSocketState]; +} + +- (void)handleSocketFailure +{ + OWSAssertIsOnMainThread(); + + [self closeWebSocket]; + + if ([self shouldSocketBeOpen]) { + // If we should retry, use `ensureReconnect` to + // reconnect after a delay. + [self ensureReconnect]; + } else { + // Otherwise clean up and align state. + [self applyDesiredSocketState]; + } + + [self.outageDetection reportConnectionFailure]; +} + +- (void)webSocket:(SRWebSocket *)webSocket + didCloseWithCode:(NSInteger)code + reason:(nullable NSString *)reason + wasClean:(BOOL)wasClean +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(webSocket); + if (webSocket != self.websocket) { + // Ignore events from obsolete web sockets. + return; + } + + OWSLogWarn(@"Websocket did close with code: %ld", (long)code); + + [self handleSocketFailure]; +} + +- (void)webSocketHeartBeat +{ + OWSAssertIsOnMainThread(); + + if ([self shouldSocketBeOpen]) { + NSError *error; + [self.websocket sendPing:nil error:&error]; + if (error) { + OWSLogWarn(@"Error in websocket heartbeat: %@", error.localizedDescription); + [self handleSocketFailure]; + } + } else { + OWSLogWarn(@"webSocketHeartBeat closing web socket"); + [self closeWebSocket]; + [self applyDesiredSocketState]; + } +} + +- (NSString *)webSocketAuthenticationString +{ + return [NSString stringWithFormat:@"?login=%@&password=%@", + [[TSAccountManager localNumber] stringByReplacingOccurrencesOfString:@"+" withString:@"%2B"], + [TSAccountManager serverAuthToken]]; +} + +#pragma mark - Socket LifeCycle + +- (BOOL)shouldSocketBeOpen +{ + OWSAssertIsOnMainThread(); + + // Don't open socket in app extensions. + if (!CurrentAppContext().isMainApp) { + return NO; + } + + if (![TSAccountManager isRegistered]) { + return NO; + } + + if (self.signalService.isCensorshipCircumventionActive) { + OWSLogWarn(@"Skipping opening of websocket due to censorship circumvention."); + return NO; + } + + if (self.appIsActive) { + // If app is active, keep web socket alive. + return YES; + } else if (self.backgroundKeepAliveUntilDate && [self.backgroundKeepAliveUntilDate timeIntervalSinceNow] > 0.f) { + OWSAssertDebug(self.backgroundKeepAliveTimer); + // If app is doing any work in the background, keep web socket alive. + return YES; + } else { + return NO; + } +} + +- (void)requestSocketAliveForAtLeastSeconds:(CGFloat)durationSeconds +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(durationSeconds > 0.f); + + if (self.appIsActive) { + // If app is active, clean up state used to keep socket alive in background. + [self clearBackgroundState]; + } else if (!self.backgroundKeepAliveUntilDate) { + OWSAssertDebug(!self.backgroundKeepAliveUntilDate); + OWSAssertDebug(!self.backgroundKeepAliveTimer); + + OWSLogInfo(@"activating socket in the background"); + + // Set up state used to keep socket alive in background. + self.backgroundKeepAliveUntilDate = [NSDate dateWithTimeIntervalSinceNow:durationSeconds]; + + // To be defensive, clean up any existing backgroundKeepAliveTimer. + [self.backgroundKeepAliveTimer invalidate]; + // Start a new timer that will fire every second while the socket is open in the background. + // This timer will ensure we close the websocket when the time comes. + self.backgroundKeepAliveTimer = [NSTimer weakScheduledTimerWithTimeInterval:1.f + target:self + selector:@selector(backgroundKeepAliveFired) + userInfo:nil + repeats:YES]; + // Additionally, we want the reconnect timer to work in the background too. + [[NSRunLoop mainRunLoop] addTimer:self.backgroundKeepAliveTimer forMode:NSDefaultRunLoopMode]; + + __weak typeof(self) weakSelf = self; + self.backgroundTask = + [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__ + completionBlock:^(BackgroundTaskState backgroundTaskState) { + OWSAssertIsOnMainThread(); + __strong typeof(self) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + + if (backgroundTaskState == BackgroundTaskState_Expired) { + [strongSelf clearBackgroundState]; + } + [strongSelf applyDesiredSocketState]; + }]; + } else { + OWSAssertDebug(self.backgroundKeepAliveUntilDate); + OWSAssertDebug(self.backgroundKeepAliveTimer); + OWSAssertDebug([self.backgroundKeepAliveTimer isValid]); + + if ([self.backgroundKeepAliveUntilDate timeIntervalSinceNow] < durationSeconds) { + // Update state used to keep socket alive in background. + self.backgroundKeepAliveUntilDate = [NSDate dateWithTimeIntervalSinceNow:durationSeconds]; + } + } + + [self applyDesiredSocketState]; +} + +- (void)backgroundKeepAliveFired +{ + OWSAssertIsOnMainThread(); + + [self applyDesiredSocketState]; +} + +- (void)requestSocketOpen +{ + DispatchMainThreadSafe(^{ + [self observeNotificationsIfNecessary]; + + // If the app is active and the user is registered, this will + // simply open the websocket. + // + // If the app is inactive, it will open the websocket for a + // period of time. + [self requestSocketAliveForAtLeastSeconds:kBackgroundOpenSocketDurationSeconds]; + }); +} + +// This method aligns the socket state with the "desired" socket state. +- (void)applyDesiredSocketState +{ + OWSAssertIsOnMainThread(); + +#ifdef DEBUG + if (CurrentAppContext().isRunningTests) { + OWSLogWarn(@"Suppressing socket in tests."); + return; + } +#endif + + if (!AppReadiness.isAppReady) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + [AppReadiness runNowOrWhenAppIsReady:^{ + [self applyDesiredSocketState]; + }]; + }); + return; + } + + if ([self shouldSocketBeOpen]) { + if (self.state != OWSWebSocketStateOpen) { + // If we want the socket to be open and it's not open, + // start up the reconnect timer immediately (don't wait for an error). + // There's little harm in it and this will make us more robust to edge + // cases. + [self ensureReconnect]; + } + [self ensureWebsocketIsOpen]; + } else { + [self clearBackgroundState]; + [self clearReconnect]; + [self closeWebSocket]; + } +} + +- (void)clearBackgroundState +{ + OWSAssertIsOnMainThread(); + + self.backgroundKeepAliveUntilDate = nil; + [self.backgroundKeepAliveTimer invalidate]; + self.backgroundKeepAliveTimer = nil; + self.backgroundTask = nil; +} + +#pragma mark - Reconnect + +- (void)ensureReconnect +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug([self shouldSocketBeOpen]); + + if (self.reconnectTimer) { + OWSAssertDebug([self.reconnectTimer isValid]); + } else { + // TODO: It'd be nice to do exponential backoff. + self.reconnectTimer = [NSTimer timerWithTimeInterval:kSocketReconnectDelaySeconds + target:self + selector:@selector(applyDesiredSocketState) + userInfo:nil + repeats:YES]; + // Additionally, we want the reconnect timer to work in the background too. + [[NSRunLoop mainRunLoop] addTimer:self.reconnectTimer forMode:NSDefaultRunLoopMode]; + } +} + +- (void)clearReconnect +{ + OWSAssertIsOnMainThread(); + + [self.reconnectTimer invalidate]; + self.reconnectTimer = nil; +} + +#pragma mark - Notifications + +- (void)applicationDidBecomeActive:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + self.appIsActive = YES; + [self applyDesiredSocketState]; +} + +- (void)applicationWillResignActive:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + self.appIsActive = NO; + // TODO: It might be nice to use `requestSocketAliveForAtLeastSeconds:` to + // keep the socket open for a few seconds after the app is + // inactivated. + [self applyDesiredSocketState]; +} + +- (void)registrationStateDidChange:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + [self applyDesiredSocketState]; +} + +- (void)isCensorshipCircumventionActiveDidChange:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + [self applyDesiredSocketState]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Network/WebSockets/TSSocketManager.h b/SignalServiceKit/src/Network/WebSockets/TSSocketManager.h index a63edde56..6d729cd56 100644 --- a/SignalServiceKit/src/Network/WebSockets/TSSocketManager.h +++ b/SignalServiceKit/src/Network/WebSockets/TSSocketManager.h @@ -2,34 +2,27 @@ // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // -#import +#import "OWSWebSocket.h" NS_ASSUME_NONNULL_BEGIN -static void *SocketManagerStateObservationContext = &SocketManagerStateObservationContext; - -extern NSString *const kNSNotification_SocketManagerStateDidChange; - -typedef NS_ENUM(NSUInteger, SocketManagerState) { - SocketManagerStateClosed, - SocketManagerStateConnecting, - SocketManagerStateOpen, -}; - -typedef void (^TSSocketMessageSuccess)(id _Nullable responseObject); -// statusCode is zero by default, if request never made or failed. -typedef void (^TSSocketMessageFailure)(NSInteger statusCode, NSData *_Nullable responseData, NSError *error); - @class TSRequest; -@interface TSSocketManager : NSObject +@interface TSSocketManager : NSObject -@property (nonatomic, readonly) SocketManagerState state; - -+ (instancetype)shared; +@property (class, readonly, nonatomic) TSSocketManager *shared; - (instancetype)init NS_DESIGNATED_INITIALIZER; +// Returns the "best" state of any of the sockets. +// +// We surface the socket state in various places in the UI. +// We generally are trying to indicate/help resolve network +// connectivity issues. We want to show the "best" or "highest" +// socket state of the sockets. e.g. the UI should reflect +// "open" if any of the sockets is open. +- (OWSWebSocketState)highestSocketState; + // If the app is in the foreground, we'll try to open the socket unless it's already // open or connecting. // @@ -39,16 +32,17 @@ typedef void (^TSSocketMessageFailure)(NSInteger statusCode, NSData *_Nullable r // might prolong how long we keep the socket open. // // This method can be called from any thread. -+ (void)requestSocketOpen; +- (void)requestSocketOpen; // This can be used to force the socket to close and re-open, if it is open. - (void)cycleSocket; #pragma mark - Message Sending -+ (BOOL)canMakeRequests; +- (BOOL)canMakeRequestsOfType:(OWSWebSocketType)webSocketType; - (void)makeRequest:(TSRequest *)request + webSocketType:(OWSWebSocketType)webSocketType success:(TSSocketMessageSuccess)success failure:(TSSocketMessageFailure)failure; diff --git a/SignalServiceKit/src/Network/WebSockets/TSSocketManager.m b/SignalServiceKit/src/Network/WebSockets/TSSocketManager.m index ff36d37e5..fc1cb5a3b 100644 --- a/SignalServiceKit/src/Network/WebSockets/TSSocketManager.m +++ b/SignalServiceKit/src/Network/WebSockets/TSSocketManager.m @@ -3,200 +3,14 @@ // #import "TSSocketManager.h" -#import "AppContext.h" -#import "AppReadiness.h" -#import "NSNotificationCenter+OWS.h" -#import "NSTimer+OWS.h" -#import "NotificationsProtocol.h" -#import "OWSBackgroundTask.h" -#import "OWSError.h" -#import "OWSMessageManager.h" -#import "OWSMessageReceiver.h" -#import "OWSPrimaryStorage.h" -#import "OWSSignalService.h" -#import "OWSWebsocketSecurityPolicy.h" #import "SSKEnvironment.h" -#import "TSAccountManager.h" -#import "TSConstants.h" -#import "TSErrorMessage.h" -#import "TSRequest.h" -#import -#import -#import NS_ASSUME_NONNULL_BEGIN -static const CGFloat kSocketHeartbeatPeriodSeconds = 30.f; -static const CGFloat kSocketReconnectDelaySeconds = 5.f; - -// If the app is in the background, it should keep the -// websocket open if: -// -// a) It has received a notification in the last 25 seconds. -static const CGFloat kBackgroundOpenSocketDurationSeconds = 25.f; -// b) It has received a message over the socket in the last 15 seconds. -static const CGFloat kBackgroundKeepSocketAliveDurationSeconds = 15.f; -// c) It is in the process of making a request. -static const CGFloat kMakeRequestKeepSocketAliveDurationSeconds = 30.f; - -NSString *const kNSNotification_SocketManagerStateDidChange = @"kNSNotification_SocketManagerStateDidChange"; - -@interface TSSocketMessage : NSObject - -@property (nonatomic, readonly) UInt64 requestId; -@property (nonatomic, nullable) TSSocketMessageSuccess success; -@property (nonatomic, nullable) TSSocketMessageFailure failure; -@property (nonatomic) BOOL hasCompleted; -@property (nonatomic, readonly) OWSBackgroundTask *backgroundTask; - -- (instancetype)init NS_UNAVAILABLE; - -@end - -#pragma mark - - -@implementation TSSocketMessage - -- (instancetype)initWithRequestId:(UInt64)requestId - success:(TSSocketMessageSuccess)success - failure:(TSSocketMessageFailure)failure -{ - if (self = [super init]) { - OWSAssertDebug(success); - OWSAssertDebug(failure); - - _requestId = requestId; - _success = success; - _failure = failure; - _backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - } - - return self; -} - -- (void)didSucceedWithResponseObject:(id _Nullable)responseObject -{ - @synchronized(self) - { - if (self.hasCompleted) { - return; - } - self.hasCompleted = YES; - } - - OWSAssertDebug(self.success); - OWSAssertDebug(self.failure); - - TSSocketMessageSuccess success = self.success; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - success(responseObject); - }); - - self.success = nil; - self.failure = nil; -} - -- (void)timeoutIfNecessary -{ - NSError *error = OWSErrorWithCodeDescription(OWSErrorCodeMessageRequestFailed, - NSLocalizedString( - @"ERROR_DESCRIPTION_REQUEST_TIMED_OUT", @"Error indicating that a socket request timed out.")); - - [self didFailWithStatusCode:0 responseData:nil error:error]; -} - -- (void)didFailBeforeSending -{ - NSError *error = OWSErrorWithCodeDescription(OWSErrorCodeMessageRequestFailed, - NSLocalizedString(@"ERROR_DESCRIPTION_REQUEST_FAILED", @"Error indicating that a socket request failed.")); - - [self didFailWithStatusCode:0 responseData:nil error:error]; -} - -- (void)didFailWithStatusCode:(NSInteger)statusCode responseData:(nullable NSData *)responseData error:(NSError *)error -{ - OWSAssertDebug(error); - - @synchronized(self) - { - if (self.hasCompleted) { - return; - } - self.hasCompleted = YES; - } - - OWSLogError(@"didFailWithStatusCode: %zd, %@", statusCode, error); - - OWSAssertDebug(self.success); - OWSAssertDebug(self.failure); - - TSSocketMessageFailure failure = self.failure; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - failure(statusCode, responseData, error); - }); - - self.success = nil; - self.failure = nil; -} - -@end - -#pragma mark - - -// TSSocketManager's properties should only be accessed from the main thread. @interface TSSocketManager () -// This class has a few "tiers" of state. -// -// The first tier is the actual websocket and the timers used -// to keep it alive and connected. -@property (nonatomic, nullable) SRWebSocket *websocket; -@property (nonatomic, nullable) NSTimer *heartbeatTimer; -@property (nonatomic, nullable) NSTimer *reconnectTimer; - -#pragma mark - - -// The second tier is the state property. We initiate changes -// to the websocket by changing this property's value, and delegate -// events from the websocket also update this value as the websocket's -// state changes. -// -// Due to concurrency, this property can fall out of sync with the -// websocket's actual state, so we're defensive and distrustful of -// this property. -// -// We only ever access this state on the main thread. -@property (nonatomic) SocketManagerState state; - -#pragma mark - - -// The third tier is the state that is used to determine what the -// "desired" state of the websocket is. -// -// If we're keeping the socket open in the background, all three of these -// properties will be set. Otherwise (if the app is active or if we're not -// trying to keep the socket open), all three should be clear. -// -// This represents how long we're trying to keep the socket open. -@property (nonatomic, nullable) NSDate *backgroundKeepAliveUntilDate; -// This timer is used to check periodically whether we should -// close the socket. -@property (nonatomic, nullable) NSTimer *backgroundKeepAliveTimer; -// This is used to manage the iOS "background task" used to -// keep the app alive in the background. -@property (nonatomic, nullable) OWSBackgroundTask *backgroundTask; - -// We cache this value instead of consulting [UIApplication sharedApplication].applicationState, -// because UIKit only provides a "will resign active" notification, not a "did resign active" -// notification. -@property (nonatomic) BOOL appIsActive; - -@property (nonatomic) BOOL hasObservedNotifications; - -// This property should only be accessed while synchronized on the socket manager. -@property (nonatomic, readonly) NSMutableDictionary *socketMessageMap; - -@property (atomic) BOOL canMakeRequests; +@property (nonatomic) OWSWebSocket *websocketDefault; +@property (nonatomic) OWSWebSocket *websocketUD; @end @@ -214,8 +28,8 @@ NSString *const kNSNotification_SocketManagerStateDidChange = @"kNSNotification_ OWSAssertIsOnMainThread(); - _state = SocketManagerStateClosed; - _socketMessageMap = [NSMutableDictionary new]; + _websocketDefault = [[OWSWebSocket alloc] initWithWebSocketType:OWSWebSocketTypeDefault]; + _websocketUD = [[OWSWebSocket alloc] initWithWebSocketType:OWSWebSocketTypeUD]; OWSSingletonAssert(); @@ -227,44 +41,6 @@ NSString *const kNSNotification_SocketManagerStateDidChange = @"kNSNotification_ [[NSNotificationCenter defaultCenter] removeObserver:self]; } -#pragma mark - Dependencies - -- (OWSSignalService *)signalService -{ - return [OWSSignalService sharedInstance]; -} - -#pragma mark - - -// We want to observe these notifications lazily to avoid accessing -// the data store in [application: didFinishLaunchingWithOptions:]. -- (void)observeNotificationsIfNecessary -{ - if (self.hasObservedNotifications) { - return; - } - self.hasObservedNotifications = YES; - - self.appIsActive = CurrentAppContext().isMainAppAndActive; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:OWSApplicationDidBecomeActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillResignActive:) - name:OWSApplicationWillResignActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(registrationStateDidChange:) - name:RegistrationStateDidChangeNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(isCensorshipCircumventionActiveDidChange:) - name:kNSNotificationName_IsCensorshipCircumventionActiveDidChange - object:nil]; -} - + (instancetype)shared { OWSAssert(SSKEnvironment.shared.socketManager); @@ -272,836 +48,53 @@ NSString *const kNSNotification_SocketManagerStateDidChange = @"kNSNotification_ return SSKEnvironment.shared.socketManager; } -#pragma mark - Dependencies - -- (OWSMessageReceiver *)messageReceiver -{ - return SSKEnvironment.shared.messageReceiver; -} - -#pragma mark - Manage Socket - -- (void)ensureWebsocketIsOpen -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(!self.signalService.isCensorshipCircumventionActive); - - // Try to reuse the existing socket (if any) if it is in a valid state. - if (self.websocket) { - switch ([self.websocket readyState]) { - case SR_OPEN: - self.state = SocketManagerStateOpen; - return; - case SR_CONNECTING: - OWSLogVerbose(@"WebSocket is already connecting"); - self.state = SocketManagerStateConnecting; - return; - default: - break; - } - } - - OWSLogWarn(@"Creating new websocket"); - - // If socket is not already open or connecting, connect now. - // - // First we need to close the existing websocket, if any. - // The websocket delegate methods are invoked _after_ the websocket - // state changes, so we may be just learning about a socket failure - // or close event now. - self.state = SocketManagerStateClosed; - // Now open a new socket. - self.state = SocketManagerStateConnecting; -} - -- (NSString *)stringFromSocketManagerState:(SocketManagerState)state -{ - switch (state) { - case SocketManagerStateClosed: - return @"Closed"; - case SocketManagerStateOpen: - return @"Open"; - case SocketManagerStateConnecting: - return @"Connecting"; - } -} - -// We need to keep websocket state and class state tightly aligned. -// -// Sometimes we'll need to update class state to reflect changes -// in socket state; sometimes we'll need to update socket state -// and class state to reflect changes in app state. -// -// We learn about changes to socket state through websocket -// delegate methods like [webSocketDidOpen:], [didFailWithError:...] -// and [didCloseWithCode:...]. These delegate methods are sometimes -// invoked _after_ web socket state changes, so we sometimes learn -// about changes to socket state in [ensureWebsocket]. Put another way, -// it's not safe to assume we'll learn of changes to websocket state -// in the websocket delegate methods. -// -// Therefore, we use the [setState:] setter to ensure alignment between -// websocket state and class state. -- (void)setState:(SocketManagerState)state -{ - OWSAssertIsOnMainThread(); - - // If this state update is redundant, verify that - // class state and socket state are aligned. - // - // Note: it's not safe to check the socket's readyState here as - // it may have been just updated on another thread. If so, - // we'll learn of that state change soon. - if (_state == state) { - switch (state) { - case SocketManagerStateClosed: - OWSAssertDebug(!self.websocket); - break; - case SocketManagerStateOpen: - OWSAssertDebug(self.websocket); - break; - case SocketManagerStateConnecting: - OWSAssertDebug(self.websocket); - break; - } - return; - } - - OWSLogWarn(@"Socket state: %@ -> %@", - [self stringFromSocketManagerState:_state], - [self stringFromSocketManagerState:state]); - - // If this state update is _not_ redundant, - // update class state to reflect the new state. - switch (state) { - case SocketManagerStateClosed: { - [self resetSocket]; - break; - } - case SocketManagerStateOpen: { - OWSAssertDebug(self.state == SocketManagerStateConnecting); - - self.heartbeatTimer = [NSTimer timerWithTimeInterval:kSocketHeartbeatPeriodSeconds - target:self - selector:@selector(webSocketHeartBeat) - userInfo:nil - repeats:YES]; - - // Additionally, we want the ping timer to work in the background too. - [[NSRunLoop mainRunLoop] addTimer:self.heartbeatTimer forMode:NSDefaultRunLoopMode]; - - // If the socket is open, we don't need to worry about reconnecting. - [self clearReconnect]; - break; - } - case SocketManagerStateConnecting: { - // Discard the old socket which is already closed or is closing. - [self resetSocket]; - - // Create a new web socket. - NSString *webSocketConnect = [textSecureWebSocketAPI stringByAppendingString:[self webSocketAuthenticationString]]; - NSURL *webSocketConnectURL = [NSURL URLWithString:webSocketConnect]; - NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:webSocketConnectURL]; - - SRWebSocket *socket = [[SRWebSocket alloc] initWithURLRequest:request - securityPolicy:[OWSWebsocketSecurityPolicy sharedPolicy]]; - socket.delegate = self; - - [self setWebsocket:socket]; - - // [SRWebSocket open] could hypothetically call a delegate method (e.g. if - // the socket failed immediately for some reason), so we update the state - // _before_ calling it, not after. - _state = state; - self.canMakeRequests = state == SocketManagerStateOpen; - [socket open]; - [self failAllPendingSocketMessagesIfNecessary]; - return; - } - } - - _state = state; - self.canMakeRequests = state == SocketManagerStateOpen; - - [self failAllPendingSocketMessagesIfNecessary]; - [self notifyStatusChange]; -} - -- (void)notifyStatusChange -{ - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:kNSNotification_SocketManagerStateDidChange - object:nil - userInfo:nil]; -} - -#pragma mark - - -- (void)resetSocket { - OWSAssertIsOnMainThread(); - - self.websocket.delegate = nil; - [self.websocket close]; - self.websocket = nil; - [self.heartbeatTimer invalidate]; - self.heartbeatTimer = nil; -} - -- (void)closeWebSocket -{ - OWSAssertIsOnMainThread(); - - if (self.websocket) { - OWSLogWarn(@"closeWebSocket."); - } - - self.state = SocketManagerStateClosed; -} - -#pragma mark - Message Sending - -+ (BOOL)canMakeRequests -{ - if (!CurrentAppContext().isMainApp) { - return NO; - } - return TSSocketManager.shared.canMakeRequests; -} - -- (void)makeRequest:(TSRequest *)request success:(TSSocketMessageSuccess)success failure:(TSSocketMessageFailure)failure -{ - OWSAssertDebug(request); - OWSAssertDebug(request.HTTPMethod.length > 0); - OWSAssertDebug(success); - OWSAssertDebug(failure); - - TSSocketMessage *socketMessage = - [[TSSocketMessage alloc] initWithRequestId:[Cryptography randomUInt64] success:success failure:failure]; - - @synchronized(self) - { - self.socketMessageMap[@(socketMessage.requestId)] = socketMessage; - } - - NSURL *requestUrl = request.URL; - NSString *requestPath = [@"/" stringByAppendingString:requestUrl.path]; - - NSData *_Nullable jsonData = nil; - if (request.parameters) { - NSError *error; - jsonData = [NSJSONSerialization - dataWithJSONObject:request.parameters - options:(NSJSONWritingOptions)0 - error:&error]; - if (!jsonData || error) { - OWSFailDebug(@"could not serialize request JSON: %@", error); - [socketMessage didFailBeforeSending]; - return; - } - } - - WebSocketProtoWebSocketRequestMessageBuilder *requestBuilder = - [WebSocketProtoWebSocketRequestMessage builderWithVerb:request.HTTPMethod - path:requestPath - requestID:socketMessage.requestId]; - if (jsonData) { - // TODO: Do we need body & headers for requests with no parameters? - [requestBuilder setBody:jsonData]; - [requestBuilder setHeaders:@[ - @"content-type:application/json", - ]]; - } - - NSError *error; - WebSocketProtoWebSocketRequestMessage *_Nullable requestProto = [requestBuilder buildAndReturnError:&error]; - if (!requestProto || error) { - OWSFailDebug(@"could not build proto: %@", error); - return; - } - - WebSocketProtoWebSocketMessageBuilder *messageBuilder = - [WebSocketProtoWebSocketMessage builderWithType:WebSocketProtoWebSocketMessageTypeRequest]; - [messageBuilder setRequest:requestProto]; - - NSData *_Nullable messageData = [messageBuilder buildSerializedDataAndReturnError:&error]; - if (!messageData || error) { - OWSFailDebug(@"could not serialize proto: %@.", error); - [socketMessage didFailBeforeSending]; - return; - } - - if (!self.canMakeRequests) { - OWSLogError(@"makeRequest: socket not open."); - [socketMessage didFailBeforeSending]; - return; - } - - BOOL wasScheduled = [self.websocket sendDataNoCopy:messageData error:&error]; - if (!wasScheduled || error) { - OWSFailDebug(@"could not send socket request: %@", error); - [socketMessage didFailBeforeSending]; - return; - } - OWSLogVerbose(@"message scheduled: %llu, %@, %@, %zd.", - socketMessage.requestId, - request.HTTPMethod, - requestPath, - jsonData.length); - - const int64_t kSocketTimeoutSeconds = 10; - __weak TSSocketMessage *weakSocketMessage = socketMessage; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kSocketTimeoutSeconds * NSEC_PER_SEC), - dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), - ^{ - [weakSocketMessage timeoutIfNecessary]; - }); -} - -- (void)processWebSocketResponseMessage:(WebSocketProtoWebSocketResponseMessage *)message -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(message); - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [self processWebSocketResponseMessageAsync:message]; - }); -} - -- (void)processWebSocketResponseMessageAsync:(WebSocketProtoWebSocketResponseMessage *)message -{ - OWSAssertDebug(message); - - OWSLogInfo(@"received WebSocket response."); - - DispatchMainThreadSafe(^{ - [self requestSocketAliveForAtLeastSeconds:kMakeRequestKeepSocketAliveDurationSeconds]; - }); - - UInt64 requestId = message.requestID; - UInt32 responseStatus = message.status; - NSString *_Nullable responseMessage; - if (message.hasMessage) { - responseMessage = message.message; - } - NSData *_Nullable responseData; - if (message.hasBody) { - responseData = message.body; - } - - BOOL hasValidResponse = YES; - id responseObject = responseData; - if (responseData) { - NSError *error; - id _Nullable responseJson = - [NSJSONSerialization JSONObjectWithData:responseData options:(NSJSONReadingOptions)0 error:&error]; - if (!responseJson || error) { - OWSFailDebug(@"could not parse WebSocket response JSON: %@.", error); - hasValidResponse = NO; - } else { - responseObject = responseJson; - } - } - - TSSocketMessage *_Nullable socketMessage; - @synchronized(self) - { - socketMessage = self.socketMessageMap[@(requestId)]; - [self.socketMessageMap removeObjectForKey:@(requestId)]; - } - - if (!socketMessage) { - OWSLogError(@"received response to unknown request."); - } else { - BOOL hasSuccessStatus = 200 <= responseStatus && responseStatus <= 299; - BOOL didSucceed = hasSuccessStatus && hasValidResponse; - if (didSucceed) { - [TSAccountManager.sharedInstance setIsDeregistered:NO]; - - [socketMessage didSucceedWithResponseObject:responseObject]; - } else { - if (responseStatus == 403) { - // This should be redundant with our check for the socket - // failing due to 403, but let's be thorough. - [TSAccountManager.sharedInstance setIsDeregistered:YES]; - } - - NSError *error = OWSErrorWithCodeDescription(OWSErrorCodeMessageResponseFailed, - NSLocalizedString( - @"ERROR_DESCRIPTION_RESPONSE_FAILED", @"Error indicating that a socket response failed.")); - [socketMessage didFailWithStatusCode:(NSInteger)responseStatus responseData:responseData error:error]; - } - } -} - -- (void)failAllPendingSocketMessagesIfNecessary +- (OWSWebSocket *)webSocketOfType:(OWSWebSocketType)webSocketType { - if (!self.canMakeRequests) { - [self failAllPendingSocketMessages]; + switch (webSocketType) { + case OWSWebSocketTypeDefault: + return self.websocketDefault; + case OWSWebSocketTypeUD: + return self.websocketUD; } } -- (void)failAllPendingSocketMessages +- (BOOL)canMakeRequestsOfType:(OWSWebSocketType)webSocketType { - NSArray *socketMessages; - @synchronized(self) - { - socketMessages = self.socketMessageMap.allValues; - [self.socketMessageMap removeAllObjects]; - } - - OWSLogInfo(@"failAllPendingSocketMessages: %zd.", socketMessages.count); - - for (TSSocketMessage *socketMessage in socketMessages) { - [socketMessage didFailBeforeSending]; - } -} - -#pragma mark - Delegate methods - -- (void)webSocketDidOpen:(SRWebSocket *)webSocket { - OWSAssertIsOnMainThread(); - OWSAssertDebug(webSocket); - if (webSocket != self.websocket) { - // Ignore events from obsolete web sockets. - return; - } - - self.state = SocketManagerStateOpen; - - // If socket opens, we know we're not de-registered. - [TSAccountManager.sharedInstance setIsDeregistered:NO]; - - [OutageDetection.sharedManager reportConnectionSuccess]; -} - -- (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error { - OWSAssertIsOnMainThread(); - OWSAssertDebug(webSocket); - if (webSocket != self.websocket) { - // Ignore events from obsolete web sockets. - return; - } - - OWSLogError(@"Websocket did fail with error: %@", error); - - if ([error.domain isEqualToString:SRWebSocketErrorDomain] && error.code == 2132) { - NSNumber *_Nullable statusCode = error.userInfo[SRHTTPResponseErrorKey]; - if (statusCode.unsignedIntegerValue == 403) { - [TSAccountManager.sharedInstance setIsDeregistered:YES]; - } - } - - [self handleSocketFailure]; + return [self webSocketOfType:webSocketType].canMakeRequests; } -- (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(NSData *)data { - OWSAssertIsOnMainThread(); - OWSAssertDebug(webSocket); - - if (webSocket != self.websocket) { - // Ignore events from obsolete web sockets. - return; - } - - // If we receive a response, we know we're not de-registered. - [TSAccountManager.sharedInstance setIsDeregistered:NO]; - - NSError *error; - WebSocketProtoWebSocketMessage *_Nullable wsMessage = [WebSocketProtoWebSocketMessage parseData:data error:&error]; - if (!wsMessage || error) { - OWSFailDebug(@"could not parse proto: %@", error); - return; - } - - if (wsMessage.type == WebSocketProtoWebSocketMessageTypeRequest) { - [self processWebSocketRequestMessage:wsMessage.request]; - } else if (wsMessage.type == WebSocketProtoWebSocketMessageTypeResponse) { - [self processWebSocketResponseMessage:wsMessage.response]; - } else { - OWSLogWarn(@"webSocket:didReceiveMessage: unknown."); - } -} - -- (void)processWebSocketRequestMessage:(WebSocketProtoWebSocketRequestMessage *)message +- (void)makeRequest:(TSRequest *)request + webSocketType:(OWSWebSocketType)webSocketType + success:(TSSocketMessageSuccess)success + failure:(TSSocketMessageFailure)failure { - OWSAssertIsOnMainThread(); - - OWSLogInfo(@"Got message with verb: %@ and path: %@", message.verb, message.path); - - // If we receive a message over the socket while the app is in the background, - // prolong how long the socket stays open. - [self requestSocketAliveForAtLeastSeconds:kBackgroundKeepSocketAliveDurationSeconds]; - - if ([message.path isEqualToString:@"/api/v1/message"] && [message.verb isEqualToString:@"PUT"]) { - - __block OWSBackgroundTask *_Nullable backgroundTask = - [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - BOOL success = NO; - @try { - NSData *_Nullable decryptedPayload = - [Cryptography decryptAppleMessagePayload:message.body - withSignalingKey:TSAccountManager.signalingKey]; - - if (!decryptedPayload) { - OWSLogWarn(@"Failed to decrypt incoming payload or bad HMAC"); - } else { - [self.messageReceiver handleReceivedEnvelopeData:decryptedPayload]; - success = YES; - } - } @catch (NSException *exception) { - OWSFailDebug(@"Received an invalid envelope: %@", exception.debugDescription); - // TODO: Add analytics. - } - - if (!success) { - [[OWSPrimaryStorage.sharedManager newDatabaseConnection] readWriteWithBlock:^( - YapDatabaseReadWriteTransaction *transaction) { - TSErrorMessage *errorMessage = [TSErrorMessage corruptedMessageInUnknownThread]; - [SSKEnvironment.shared.notificationsManager notifyUserForThreadlessErrorMessage:errorMessage - transaction:transaction]; - }]; - } - - dispatch_async(dispatch_get_main_queue(), ^{ - [self sendWebSocketMessageAcknowledgement:message]; - OWSAssertDebug(backgroundTask); - backgroundTask = nil; - }); - }); - } else if ([message.path isEqualToString:@"/api/v1/queue/empty"]) { - // Queue is drained. - - [self sendWebSocketMessageAcknowledgement:message]; - } else { - OWSLogWarn(@"Unsupported WebSocket Request"); - - [self sendWebSocketMessageAcknowledgement:message]; - } + [[self webSocketOfType:webSocketType] makeRequest:request success:success failure:failure]; } -- (void)sendWebSocketMessageAcknowledgement:(WebSocketProtoWebSocketRequestMessage *)request +- (void)requestSocketOpen { - OWSAssertIsOnMainThread(); - - NSError *error; - - WebSocketProtoWebSocketResponseMessageBuilder *responseBuilder = - [WebSocketProtoWebSocketResponseMessage builderWithRequestID:request.requestID status:200]; - [responseBuilder setMessage:@"OK"]; - WebSocketProtoWebSocketResponseMessage *_Nullable response = [responseBuilder buildAndReturnError:&error]; - if (!response || error) { - OWSFailDebug(@"could not build proto: %@", error); - return; - } - - WebSocketProtoWebSocketMessageBuilder *messageBuilder = - [WebSocketProtoWebSocketMessage builderWithType:WebSocketProtoWebSocketMessageTypeResponse]; - [messageBuilder setResponse:response]; - - NSData *_Nullable messageData = [messageBuilder buildSerializedDataAndReturnError:&error]; - if (!messageData || error) { - OWSFailDebug(@"could not serialize proto: %@", error); - return; - } - - [self.websocket sendDataNoCopy:messageData error:&error]; - if (error) { - OWSLogWarn(@"Error while trying to write on websocket %@", error); - [self handleSocketFailure]; - } + [self.websocketDefault requestSocketOpen]; + [self.websocketUD requestSocketOpen]; } - (void)cycleSocket { - OWSAssertIsOnMainThread(); - - [self closeWebSocket]; - - [self applyDesiredSocketState]; -} - -- (void)handleSocketFailure -{ - OWSAssertIsOnMainThread(); - - [self closeWebSocket]; - - if ([self shouldSocketBeOpen]) { - // If we should retry, use `ensureReconnect` to - // reconnect after a delay. - [self ensureReconnect]; - } else { - // Otherwise clean up and align state. - [self applyDesiredSocketState]; - } - - [OutageDetection.sharedManager reportConnectionFailure]; + [self.websocketDefault cycleSocket]; + [self.websocketUD cycleSocket]; } -- (void)webSocket:(SRWebSocket *)webSocket - didCloseWithCode:(NSInteger)code - reason:(nullable NSString *)reason - wasClean:(BOOL)wasClean +- (OWSWebSocketState)highestSocketState { - OWSAssertIsOnMainThread(); - OWSAssertDebug(webSocket); - if (webSocket != self.websocket) { - // Ignore events from obsolete web sockets. - return; - } - - OWSLogWarn(@"Websocket did close with code: %ld", (long)code); - - [self handleSocketFailure]; -} - -- (void)webSocketHeartBeat { - OWSAssertIsOnMainThread(); - - if ([self shouldSocketBeOpen]) { - NSError *error; - [self.websocket sendPing:nil error:&error]; - if (error) { - OWSLogWarn(@"Error in websocket heartbeat: %@", error.localizedDescription); - [self handleSocketFailure]; - } + if (self.websocketDefault.state == OWSWebSocketStateOpen || self.websocketUD.state == OWSWebSocketStateOpen) { + return OWSWebSocketStateOpen; + } else if (self.websocketDefault.state == OWSWebSocketStateConnecting + || self.websocketUD.state == OWSWebSocketStateConnecting) { + return OWSWebSocketStateConnecting; } else { - OWSLogWarn(@"webSocketHeartBeat closing web socket"); - [self closeWebSocket]; - [self applyDesiredSocketState]; + return OWSWebSocketStateClosed; } } -- (NSString *)webSocketAuthenticationString { - return [NSString stringWithFormat:@"?login=%@&password=%@", - [[TSAccountManager localNumber] stringByReplacingOccurrencesOfString:@"+" withString:@"%2B"], - [TSAccountManager serverAuthToken]]; -} - -#pragma mark - Socket LifeCycle - -- (BOOL)shouldSocketBeOpen -{ - OWSAssertIsOnMainThread(); - - // Don't open socket in app extensions. - if (!CurrentAppContext().isMainApp) { - return NO; - } - - if (![TSAccountManager isRegistered]) { - return NO; - } - - if (self.signalService.isCensorshipCircumventionActive) { - OWSLogWarn(@"Skipping opening of websocket due to censorship circumvention."); - return NO; - } - - if (self.appIsActive) { - // If app is active, keep web socket alive. - return YES; - } else if (self.backgroundKeepAliveUntilDate && [self.backgroundKeepAliveUntilDate timeIntervalSinceNow] > 0.f) { - OWSAssertDebug(self.backgroundKeepAliveTimer); - // If app is doing any work in the background, keep web socket alive. - return YES; - } else { - return NO; - } -} - -- (void)requestSocketAliveForAtLeastSeconds:(CGFloat)durationSeconds -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(durationSeconds > 0.f); - - if (self.appIsActive) { - // If app is active, clean up state used to keep socket alive in background. - [self clearBackgroundState]; - } else if (!self.backgroundKeepAliveUntilDate) { - OWSAssertDebug(!self.backgroundKeepAliveUntilDate); - OWSAssertDebug(!self.backgroundKeepAliveTimer); - - OWSLogInfo(@"activating socket in the background"); - - // Set up state used to keep socket alive in background. - self.backgroundKeepAliveUntilDate = [NSDate dateWithTimeIntervalSinceNow:durationSeconds]; - - // To be defensive, clean up any existing backgroundKeepAliveTimer. - [self.backgroundKeepAliveTimer invalidate]; - // Start a new timer that will fire every second while the socket is open in the background. - // This timer will ensure we close the websocket when the time comes. - self.backgroundKeepAliveTimer = [NSTimer weakScheduledTimerWithTimeInterval:1.f - target:self - selector:@selector(backgroundKeepAliveFired) - userInfo:nil - repeats:YES]; - // Additionally, we want the reconnect timer to work in the background too. - [[NSRunLoop mainRunLoop] addTimer:self.backgroundKeepAliveTimer forMode:NSDefaultRunLoopMode]; - - __weak typeof(self) weakSelf = self; - self.backgroundTask = - [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__ - completionBlock:^(BackgroundTaskState backgroundTaskState) { - OWSAssertIsOnMainThread(); - __strong typeof(self) strongSelf = weakSelf; - if (!strongSelf) { - return; - } - - if (backgroundTaskState == BackgroundTaskState_Expired) { - [strongSelf clearBackgroundState]; - } - [strongSelf applyDesiredSocketState]; - }]; - } else { - OWSAssertDebug(self.backgroundKeepAliveUntilDate); - OWSAssertDebug(self.backgroundKeepAliveTimer); - OWSAssertDebug([self.backgroundKeepAliveTimer isValid]); - - if ([self.backgroundKeepAliveUntilDate timeIntervalSinceNow] < durationSeconds) { - // Update state used to keep socket alive in background. - self.backgroundKeepAliveUntilDate = [NSDate dateWithTimeIntervalSinceNow:durationSeconds]; - } - } - - [self applyDesiredSocketState]; -} - -- (void)backgroundKeepAliveFired -{ - OWSAssertIsOnMainThread(); - - [self applyDesiredSocketState]; -} - -+ (void)requestSocketOpen -{ - DispatchMainThreadSafe(^{ - [[self shared] observeNotificationsIfNecessary]; - - // If the app is active and the user is registered, this will - // simply open the websocket. - // - // If the app is inactive, it will open the websocket for a - // period of time. - [[self shared] requestSocketAliveForAtLeastSeconds:kBackgroundOpenSocketDurationSeconds]; - }); -} - -// This method aligns the socket state with the "desired" socket state. -- (void)applyDesiredSocketState -{ - OWSAssertIsOnMainThread(); - -#ifdef DEBUG - if (CurrentAppContext().isRunningTests) { - OWSLogWarn(@"Suppressing socket in tests."); - return; - } -#endif - - if (!AppReadiness.isAppReady) { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [AppReadiness runNowOrWhenAppIsReady:^{ - [self applyDesiredSocketState]; - }]; - }); - return; - } - - if ([self shouldSocketBeOpen]) { - if (self.state != SocketManagerStateOpen) { - // If we want the socket to be open and it's not open, - // start up the reconnect timer immediately (don't wait for an error). - // There's little harm in it and this will make us more robust to edge - // cases. - [self ensureReconnect]; - } - [self ensureWebsocketIsOpen]; - } else { - [self clearBackgroundState]; - [self clearReconnect]; - [self closeWebSocket]; - } -} - -- (void)clearBackgroundState -{ - OWSAssertIsOnMainThread(); - - self.backgroundKeepAliveUntilDate = nil; - [self.backgroundKeepAliveTimer invalidate]; - self.backgroundKeepAliveTimer = nil; - self.backgroundTask = nil; -} - -#pragma mark - Reconnect - -- (void)ensureReconnect -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug([self shouldSocketBeOpen]); - - if (self.reconnectTimer) { - OWSAssertDebug([self.reconnectTimer isValid]); - } else { - // TODO: It'd be nice to do exponential backoff. - self.reconnectTimer = [NSTimer timerWithTimeInterval:kSocketReconnectDelaySeconds - target:self - selector:@selector(applyDesiredSocketState) - userInfo:nil - repeats:YES]; - // Additionally, we want the reconnect timer to work in the background too. - [[NSRunLoop mainRunLoop] addTimer:self.reconnectTimer forMode:NSDefaultRunLoopMode]; - } -} - -- (void)clearReconnect -{ - OWSAssertIsOnMainThread(); - - [self.reconnectTimer invalidate]; - self.reconnectTimer = nil; -} - -#pragma mark - Notifications - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.appIsActive = YES; - [self applyDesiredSocketState]; -} - -- (void)applicationWillResignActive:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.appIsActive = NO; - // TODO: It might be nice to use `requestSocketAliveForAtLeastSeconds:` to - // keep the socket open for a few seconds after the app is - // inactivated. - [self applyDesiredSocketState]; -} - -- (void)registrationStateDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self applyDesiredSocketState]; -} - -- (void)isCensorshipCircumventionActiveDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self applyDesiredSocketState]; -} - @end NS_ASSUME_NONNULL_END