Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 17 additions & 61 deletions ios/CodePush/CodePush.m
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@
#import "CodePush.h"
#import "CodePushErrorUtils.h"

@interface CodePush () <RCTBridgeModule, RCTFrameUpdateObserver>
#if SWIFT_PACKAGE
@import CodePushCore;
#elif __has_include(<CodePush/CodePush-Swift.h>)
#import <CodePush/CodePush-Swift.h>
#else
#import "CodePush-Swift.h"
#endif

@interface CodePush () <RCTBridgeModule>
@end

@implementation CodePush {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }];
}

/*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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];

Expand All @@ -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:^{
Expand All @@ -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);
}];
}
Expand Down Expand Up @@ -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
17 changes: 9 additions & 8 deletions ios/CodePush/CodePushDownloadHandler.m
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,9 @@ - (void)download:(NSString *)url {
NSURLConnection *connection = [[NSURLConnection alloc] initWithRequest:request
delegate:self
startImmediately:NO];
if ([NSOperationQueue instancesRespondToSelector:@selector(setUnderlyingQueue:)]) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Available since iOS 8, no need to use a runtime availability check: https://developer.apple.com/documentation/foundation/operationqueue/underlyingqueue

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];
}
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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..<interval).contains(time - lastSendTime) {
return false
}
lastSendTime = time
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import XCTest
@testable import CodePushCore

final class ProgressThrottleTests: XCTestCase {
private var time: TimeInterval = 1000
private var throttle: ProgressThrottle!

override func setUp() {
super.setUp()
throttle = ProgressThrottle(interval: 0.1, now: { [unowned self] in self.time })
}

func testSendsFirstChunkImmediately() {
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 100))
}

func testDoesNotSendWithinInterval() {
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 100))
time += 0.05
XCTAssertFalse(throttle.shouldSend(expectedBytes: 300, receivedBytes: 200))
time += 0.049
XCTAssertFalse(throttle.shouldSend(expectedBytes: 300, receivedBytes: 250))
}

func testSendsAfterInterval() {
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 100))
time += 0.1
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 200))
}

func testIntervalCountsFromLastSendNotLastChunk() {
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 100))
time += 0.06
XCTAssertFalse(throttle.shouldSend(expectedBytes: 300, receivedBytes: 150))
time += 0.06
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 200))
}

func testSendsWhenClockJumpsBackwards() {
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 100))
time -= 60
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 150))
time += 0.05
XCTAssertFalse(throttle.shouldSend(expectedBytes: 300, receivedBytes: 200))
}

func testSendsCompletedProgressWithinInterval() {
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 100))
time += 0.01
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 300))
}

func testThrottlesUnknownSizeUntilCompleted() {
XCTAssertTrue(throttle.shouldSend(expectedBytes: -1, receivedBytes: 100))
time += 0.01
XCTAssertFalse(throttle.shouldSend(expectedBytes: -1, receivedBytes: 300))
// The downloader reports the final size once the response ends.
XCTAssertTrue(throttle.shouldSend(expectedBytes: 300, receivedBytes: 300))
}

func testBoundsEventRateForManySmallChunks() {
// 1 MB in 1 KB chunks, one every millisecond: one second of downloading.
let total: Int64 = 1024 * 1024
var sent: [Int64] = []
for chunk in 1...1024 {
time += 0.001
let received = Int64(chunk) * 1024
if throttle.shouldSend(expectedBytes: total, receivedBytes: received) {
sent.append(received)
}
}

XCTAssertLessThanOrEqual(sent.count, 12)
XCTAssertGreaterThanOrEqual(sent.count, 10)
XCTAssertEqual(sent.last, total)
}
}
Loading