diff --git a/ios/CodePush/CodePush.m b/ios/CodePush/CodePush.m index e9225f02..2d5840a9 100644 --- a/ios/CodePush/CodePush.m +++ b/ios/CodePush/CodePush.m @@ -18,7 +18,15 @@ #import "CodePush.h" #import "CodePushErrorUtils.h" -@interface CodePush () +#if SWIFT_PACKAGE +@import CodePushCore; +#elif __has_include() +#import +#else +#import "CodePush-Swift.h" +#endif + +@interface CodePush () @end @implementation CodePush { @@ -29,11 +37,6 @@ @implementation CodePush { CodePushInstallMode _installMode; NSTimer *_appSuspendTimer; - // Used to coordinate the dispatching of download progress events to JS. - long long _latestExpectedContentLength; - long long _latestReceivedConentLength; - BOOL _didUpdateProgress; - BOOL _allowed; BOOL _restartInProgress; NSMutableArray *_restartQueue; @@ -291,18 +294,6 @@ + (void)setUsingTestConfiguration:(BOOL)shouldUseTestConfiguration #pragma mark - Private API methods @synthesize methodQueue = _methodQueue; -@synthesize pauseCallback = _pauseCallback; -@synthesize paused = _paused; - -- (void)setPaused:(BOOL)paused -{ - if (_paused != paused) { - _paused = paused; - if (_pauseCallback) { - _pauseCallback(); - } - } -} /* * This method is used to clear updates that are installed @@ -361,15 +352,9 @@ - (void)dealloc [[NSNotificationCenter defaultCenter] removeObserver:self]; } -- (void)dispatchDownloadProgressEvent { - // Notify the script-side about the progress - [self sendEventWithName:DownloadProgressEvent - body:@{ - @"totalBytes" : [NSNumber - numberWithLongLong:_latestExpectedContentLength], - @"receivedBytes" : [NSNumber - numberWithLongLong:_latestReceivedConentLength] - }]; +- (void)dispatchDownloadProgressEvent:(long long)expectedBytes receivedBytes:(long long)receivedBytes { + [self sendEventWithName:DownloadProgressEvent + body:@{ @"totalBytes" : @(expectedBytes), @"receivedBytes" : @(receivedBytes) }]; } /* @@ -521,7 +506,6 @@ - (void)initializeUpdateAfterRestart #ifdef DEBUG [self clearDebugUpdates]; #endif - self.paused = YES; NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults]; NSDictionary *pendingUpdate = [preferences objectForKey:PendingUpdateKey]; if (pendingUpdate) { @@ -875,12 +859,9 @@ -(void)loadBundleOnTick:(NSTimer *)timer { forKey:BinaryBundleDateKey]; } - if (notifyProgress) { - // Set up and unpause the frame observer so that it can emit - // progress events every frame if the progress is updated. - _didUpdateProgress = NO; - self.paused = NO; - } + // The network reports progress once per chunk, which can be hundreds of times per second. + // All download callbacks run on _methodQueue, so the throttle needs no locking. + CPProgressThrottle *progressThrottle = notifyProgress ? [CPProgressThrottle new] : nil; NSString * publicKey = [[CodePushConfig current] publicKey]; @@ -891,18 +872,9 @@ -(void)loadBundleOnTick:(NSTimer *)timer { operationQueue:_methodQueue // The download is progressing forward progressCallback:^(long long expectedContentLength, long long receivedContentLength) { - // Update the download progress so that the frame observer can notify the JS side - _latestExpectedContentLength = expectedContentLength; - _latestReceivedConentLength = receivedContentLength; - _didUpdateProgress = YES; - - // If the download is completed, stop observing frame - // updates and synchronously send the last event. - if (expectedContentLength == receivedContentLength) { - _didUpdateProgress = NO; - self.paused = YES; + if ([progressThrottle shouldSendExpectedBytes:expectedContentLength receivedBytes:receivedContentLength]) { + [self dispatchDownloadProgressEvent:expectedContentLength receivedBytes:receivedContentLength]; } - [self dispatchDownloadProgressEvent]; } // The download completed doneCallback:^{ @@ -922,10 +894,6 @@ -(void)loadBundleOnTick:(NSTimer *)timer { if ([CodePushErrorUtils isCodePushError:err]) { [[self class] saveFailedUpdate:mutableUpdatePackage]; } - - // Stop observing frame updates if the download fails. - _didUpdateProgress = NO; - self.paused = YES; reject([NSString stringWithFormat: @"%lu", (long)err.code], err.localizedDescription, err); }]; } @@ -1278,16 +1246,4 @@ - (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending [CodePushTelemetryManager saveStatusReportForRetry:statusReport]; } -#pragma mark - RCTFrameUpdateObserver Methods - -- (void)didUpdateFrame:(RCTFrameUpdate *)update -{ - if (!_didUpdateProgress) { - return; - } - - [self dispatchDownloadProgressEvent]; - _didUpdateProgress = NO; -} - @end diff --git a/ios/CodePush/CodePushDownloadHandler.m b/ios/CodePush/CodePushDownloadHandler.m index 34400430..2c26e63b 100644 --- a/ios/CodePush/CodePushDownloadHandler.m +++ b/ios/CodePush/CodePushDownloadHandler.m @@ -30,14 +30,9 @@ - (void)download:(NSString *)url { NSURLConnection *connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO]; - if ([NSOperationQueue instancesRespondToSelector:@selector(setUnderlyingQueue:)]) { - NSOperationQueue *delegateQueue = [NSOperationQueue new]; - delegateQueue.underlyingQueue = self.operationQueue; - [connection setDelegateQueue:delegateQueue]; - } else { - [connection scheduleInRunLoop:[NSRunLoop mainRunLoop] - forMode:NSDefaultRunLoopMode]; - } + NSOperationQueue *delegateQueue = [NSOperationQueue new]; + delegateQueue.underlyingQueue = self.operationQueue; + [connection setDelegateQueue:delegateQueue]; [connection start]; } @@ -125,6 +120,12 @@ - (void)connectionDidFinishLoading:(NSURLConnection *)connection { // We should have received all of the bytes if this is called. assert(self.receivedContentLength == self.expectedContentLength); } + // Progress consumers rely on the last event having received == expected to send it unthrottled. + // Asserts are compiled out in release, so a mismatched Content-Length also lands here. + if (self.receivedContentLength != self.expectedContentLength) { + self.expectedContentLength = self.receivedContentLength; + self.progressCallback(self.expectedContentLength, self.receivedContentLength); + } BOOL isZip = _header[0] == 'P' && _header[1] == 'K' && _header[2] == 3 && _header[3] == 4; CPLog(@"Downloaded update package. httpStatus=%ld bytes=%lld zip=%@", (long)self.statusCode, self.receivedContentLength, isZip ? @"true" : @"false"); diff --git a/ios/CodePushCore/Sources/CodePushCore/Download/ProgressThrottle.swift b/ios/CodePushCore/Sources/CodePushCore/Download/ProgressThrottle.swift new file mode 100644 index 00000000..a5a47685 --- /dev/null +++ b/ios/CodePushCore/Sources/CodePushCore/Download/ProgressThrottle.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Limits how often download progress reaches JS. +/// +/// One instance tracks one download. It isn't thread-safe: call it from one serial queue. +@objc(CPProgressThrottle) +public final class ProgressThrottle: NSObject { + private let interval: TimeInterval + private let now: () -> TimeInterval + private var lastSendTime: TimeInterval? + + init(interval: TimeInterval, now: @escaping () -> TimeInterval) { + self.interval = interval + self.now = now + } + + @objc public override convenience init() { + // Not systemUptime: that's a required-reason API, and the throttle doesn't need a monotonic clock. + self.init(interval: 0.1, now: { Date().timeIntervalSinceReferenceDate }) + } + + /// The downloader's last event always has `receivedBytes == expectedBytes`, so it's never held + /// back. The download's done callback only runs after unzipping and verifying the update. + @objc(shouldSendExpectedBytes:receivedBytes:) + public func shouldSend(expectedBytes: Int64, receivedBytes: Int64) -> Bool { + let time = now() + // The wall clock can jump backwards. Sending restarts the interval instead of stalling. + if receivedBytes != expectedBytes, let lastSendTime, (0..