diff --git a/android/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java b/android/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java index 9bf8b0e9..ed6c14e7 100644 --- a/android/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +++ b/android/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java @@ -361,15 +361,15 @@ public void downloadUpdate(final ReadableMap updatePackage, final boolean notify public void run() { try { JSONObject mutableUpdatePackage = CodePushUtils.convertReadableToJsonObject(updatePackage); + final DownloadProgressEventDispatcher progressEventDispatcher = new DownloadProgressEventDispatcher( + new DownloadProgressEventDispatcher.EventEmitter() { + @Override + public void emit(DownloadProgress downloadProgress) { + emitDownloadProgressEvent(downloadProgress); + } + }); JSONObject updateArchiveResult = mUpdateManager.downloadPackage(mutableUpdatePackage, mCodePush.getAssetsBundleFileName(), new DownloadProgressCallback() { private volatile boolean hasScheduledNextFrame = false; - private final DownloadProgressEventDispatcher progressEventDispatcher = new DownloadProgressEventDispatcher( - new DownloadProgressEventDispatcher.EventEmitter() { - @Override - public void emit(DownloadProgress downloadProgress) { - emitDownloadProgressEvent(downloadProgress); - } - }); @Override public void onDownloadStart() { @@ -378,11 +378,14 @@ public void onDownloadStart() { @Override public void call(DownloadProgress downloadProgress) { - if (!notifyProgress || downloadProgress == null) { + if (downloadProgress == null) { return; } - progressEventDispatcher.record(downloadProgress); + if (!notifyProgress) { + return; + } + // If the download is completed, synchronously send the last event. if (downloadProgress.isCompleted()) { progressEventDispatcher.dispatchLatest(); @@ -414,6 +417,7 @@ public void doFrame(long frameTimeNanos) { JSONObject newPackage = mUpdateManager.getPackage(CodePushUtils.tryGetString(updatePackage, CodePushConstants.PACKAGE_HASH_KEY)); WritableMap downloadedPackage = CodePushUtils.convertJsonObjectToWritable(newPackage); + downloadedPackage.putDouble("downloadBytes", progressEventDispatcher.getDownloadBytes()); if (updateArchiveResult != null) { // The result describes this download rather than the update, so it is // put on what this call resolves with and nowhere else: the metadata diff --git a/android/src/main/java/com/microsoft/codepush/react/DownloadProgressEventDispatcher.java b/android/src/main/java/com/microsoft/codepush/react/DownloadProgressEventDispatcher.java index 870f1674..580cf19a 100644 --- a/android/src/main/java/com/microsoft/codepush/react/DownloadProgressEventDispatcher.java +++ b/android/src/main/java/com/microsoft/codepush/react/DownloadProgressEventDispatcher.java @@ -23,6 +23,10 @@ synchronized void record(DownloadProgress downloadProgress) { mLatestDownloadProgress = downloadProgress; } + synchronized long getDownloadBytes() { + return mLatestDownloadProgress == null ? 0 : mLatestDownloadProgress.getReceivedBytes(); + } + synchronized void dispatchLatest() { if (mLatestDownloadProgress == null || mLatestDownloadProgress.getReceivedBytes() <= mLastDispatchedBytes) { return; diff --git a/android/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java b/android/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java index 4907e802..568c7f50 100644 --- a/android/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +++ b/android/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java @@ -515,6 +515,65 @@ public void call(DownloadProgress downloadProgress) { Arrays.asList((long) ERROR_PAGE.length, (long) fullArchive.length), completedBytes); } + @Test + public void reportsTheFullFileSizeWithoutProgressEvents() throws IOException { + byte[] fullArchive = zipOf(fullArchiveContents()); + String fullUrl = serve("/full.zip", fullArchive); + assertDownloadBytes(fullUpdatePackage(mPackageHash, fullUrl), fullArchive.length); + } + + @Test + public void reportsThePatchFileSizeWithoutProgressEvents() throws IOException { + byte[] patchArchive = zipOf(patchArchiveContents()); + String patchUrl = serve("/patch.zip", patchArchive); + String fullUrl = serve("/full.zip", zipOf(fullArchiveContents())); + assertDownloadBytes(updatePackage(fullUrl, patchUrl), patchArchive.length); + assertEquals(Arrays.asList("/patch.zip"), mServer.requestedPaths()); + } + + @Test + public void reportsOnlyTheFullFileSizeAfterFallbackWithoutProgressEvents() throws IOException { + byte[] fullArchive = zipOf(fullArchiveContents()); + String patchUrl = serve("/patch.zip", ERROR_PAGE); + String fullUrl = serve("/full.zip", fullArchive); + assertDownloadBytes(updatePackage(fullUrl, patchUrl), fullArchive.length); + assertEquals(Arrays.asList("/patch.zip", "/full.zip"), mServer.requestedPaths()); + } + + @Test + public void reportsTheFileSizeWithoutContentLength() throws IOException { + byte[] fullArchive = zipOf(fullArchiveContents()); + String fullUrl = mServer.serveWithoutContentLength("/full.zip", fullArchive); + assertDownloadBytes(fullUpdatePackage(mPackageHash, fullUrl), fullArchive.length); + } + + /** + * The callback below mirrors the one the native module passes, without its JS event + * scheduling: the module needs a React context and native maps this suite cannot create. + * So this pins what the update manager reports, not that the module records the progress + * before checking whether JS asked for events. + */ + private void assertDownloadBytes(JSONObject updatePackage, long expectedBytes) throws IOException { + DownloadProgressEventDispatcher dispatcher = new DownloadProgressEventDispatcher(progress -> { + fail("measuring download size must not require progress events"); + }); + updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage( + updatePackage, BUNDLE_FILE_NAME, new DownloadProgressCallback() { + @Override + public void onDownloadStart() { + dispatcher.reset(); + } + + @Override + public void call(DownloadProgress progress) { + dispatcher.record(progress); + } + }); + + assertEquals(expectedBytes, dispatcher.getDownloadBytes()); + assertInstalledContents(); + } + /** The result of a download the update had to be downloaded in full for. */ private static void assertFallbackResult(JSONObject patchResult, String expectedReason) { assertEquals("fallback", patchResult.optString("status", null)); diff --git a/android/src/test/java/com/microsoft/codepush/react/DownloadProgressEventDispatcherTest.java b/android/src/test/java/com/microsoft/codepush/react/DownloadProgressEventDispatcherTest.java index 034286ec..17388c77 100644 --- a/android/src/test/java/com/microsoft/codepush/react/DownloadProgressEventDispatcherTest.java +++ b/android/src/test/java/com/microsoft/codepush/react/DownloadProgressEventDispatcherTest.java @@ -77,4 +77,15 @@ public void progressRecordedBeforeAResetIsNotEmittedAfterIt() { assertEquals(Collections.emptyList(), mEmittedBytes); } + + @Test + public void reportsTheLastFileSizeWithoutEmittingProgress() { + mDispatcher.record(new DownloadProgress(1000, 1000)); + mDispatcher.reset(); + mDispatcher.record(new DownloadProgress(-1, 250)); + mDispatcher.record(new DownloadProgress(-1, 500)); + + assertEquals(500L, mDispatcher.getDownloadBytes()); + assertEquals(Collections.emptyList(), mEmittedBytes); + } } diff --git a/docs/api-js.md b/docs/api-js.md index 156a7dfc..a3e8c507 100644 --- a/docs/api-js.md +++ b/docs/api-js.md @@ -132,7 +132,7 @@ The `codePush` decorator accepts an "options" object that allows you to customiz * __onDownloadStart__ *((label: String) => void | Promise<void>)* - Called when the download of an available update begins, with the label of the release being downloaded. -* __onDownloadSuccess__ *((label: String) => void | Promise<void>)* - Called when the download of an update has completed successfully, with the label of the release that was downloaded. The install happens afterwards, so this says nothing about whether the update could be installed. +* __onDownloadSuccess__ *((label: String, details: DownloadSuccessDetails) => void | Promise<void>)* - Called when the download of an update has completed successfully, with the target release label and `{ baseVersion, downloadBytes }`. `baseVersion` is the previously running OTA label (the native app version for the embedded bundle). `downloadBytes` is the final downloaded file size in bytes, excluding earlier fallback attempts (`null` on older native code). No progress listener is required. The install happens afterwards, so this says nothing about whether the update could be installed. * __onRolloutSkipped__ *((label: String) => void | Promise<void>)* - Called when the device falls outside the latest release's active rollout, with the label of that release. The update check then leaves that release out of the candidates: it either resolves against the release below it, or - when the release left as the latest one is the binary version - clears the downloaded updates and restarts the app onto the binary. *NOTE: the typings declare a second `error` parameter, but the runtime only ever passes the label.* @@ -557,6 +557,7 @@ Contains details about an update that has been downloaded locally or already ins - __appVersion__: The app binary version that this update is dependent on. This is the value that was specified via the `appStoreVersion` parameter when calling the CLI's `release` command. *(String)* - __deploymentKey__: The deployment key that was used to originally download this update. *(String)* - __description__: The description of the update. This is the same value that you specified in the CLI when you released the update. *(String)* +- __downloadBytes__: The size in bytes of the final file the download used: the patch file when a patch was applied, or the full file after a fallback, excluding earlier attempts. It is only set on the package `RemotePackage.download` resolves with, and is not saved with the update, so packages returned by `getCurrentPackage` and `getUpdateMetadata` do not have it. Undefined on native code that predates download size reporting. *(Number, optional)* - __failedInstall__: Indicates whether this update has been previously installed but was rolled back. The `sync` method will automatically ignore updates which have previously failed, so you only need to worry about this property if using `checkForUpdate`. *(Boolean)* - __isFirstRun__: Indicates whether this is the first time the update has been run after being installed. This is useful for determining whether you would like to show a "What's New?" UI to the end user after installing an update. *(Boolean)* - __isMandatory__: Indicates whether the update is considered mandatory. This is the value that was specified in the CLI when the update was released. *(Boolean)* @@ -589,7 +590,7 @@ A diff that fails falls back to the patch archive, and a patch archive that fail ###### Methods -- __download(downloadProgressCallback?: Function, updateArchiveResultCallback?: Function): Promise<LocalPackage>__: Downloads the available update from the CodePush service. If a `downloadProgressCallback` is specified, it will be called periodically with a `DownloadProgress` object (`{ totalBytes: Number, receivedBytes: Number }`) that reports the progress of the download until it completes. If an `updateArchiveResultCallback` is specified, it will be called once with an `UpdateArchiveResult` object when the update was published with a binary patch, reporting which archive the update was downloaded from and why each archive that was given up on was. It only runs after the download has succeeded, and what it says nothing about is whether installing the resolved `LocalPackage` succeeds: an archive that could not be applied is reported there and the update is downloaded in full. Returns a Promise that resolves with the `LocalPackage`. +- __download(downloadProgressCallback?: Function, updateArchiveResultCallback?: Function): Promise<LocalPackage>__: Downloads the available update from the CodePush service. If a `downloadProgressCallback` is specified, it will be called periodically with a `DownloadProgress` object (`{ totalBytes: Number, receivedBytes: Number }`) that reports the progress of the download until it completes. If an `updateArchiveResultCallback` is specified, it will be called once with an `UpdateArchiveResult` object when the update was published with a binary patch, reporting which archive the update was downloaded from and why each archive that was given up on was. It only runs after the download has succeeded, and what it says nothing about is whether installing the resolved `LocalPackage` succeeds: an archive that could not be applied is reported there and the update is downloaded in full. Returns a Promise that resolves with the `LocalPackage`, whose `downloadBytes` reports the size of the downloaded file whether or not a `downloadProgressCallback` is specified. #### Enums diff --git a/docs/telemetry-callbacks.ko.md b/docs/telemetry-callbacks.ko.md index b2cbcd1e..38159377 100644 --- a/docs/telemetry-callbacks.ko.md +++ b/docs/telemetry-callbacks.ko.md @@ -17,7 +17,7 @@ | 콜백 | 호출 시점 | 전달값 | 주의할 점 | | --- | --- | --- | --- | | `onDownloadStart` | 사용 가능한 업데이트 다운로드를 시작할 때 | `(label)` | 다운로드 자체의 시작을 의미합니다. | -| `onDownloadSuccess` | 사용 가능한 업데이트 다운로드를 마쳤을 때 | `(label)` | 아직 설치 또는 정상 실행이 보장된 것은 아닙니다. | +| `onDownloadSuccess` | 사용 가능한 업데이트 다운로드를 마쳤을 때 | `(label, details)` | 아직 설치 또는 정상 실행이 보장된 것은 아닙니다. | | `onUpdateArchiveResult` | binary patch / asset diff 업데이트 다운로드가 끝난 뒤, 설치 전에 | `(label, result)` | 어떤 아카이브를 사용했고 patch fallback이 있었는지 확인합니다. | | `onUpdateSuccess` | 설치한 업데이트가 실행되고 `notifyAppReady`로 성공을 확정했을 때 | `(label)` | 실제 업데이트 성공을 기록할 시점입니다. | | `onUpdateRollback` | 설치한 업데이트 실행에 실패해 이전 버전으로 롤백할 때 | `(label)` | 다운로드 성공이나 설치 성공과는 별개입니다. | @@ -28,6 +28,23 @@ > **참고:** `onRolloutSkipped`의 타입 정의에는 두 번째 `error` 매개변수가 존재하지만, 실제로는 아무것도 전달하지 않아 항상 `undefined` 값을 갖습니다. +## 버전별 다운로드 파일 크기 기록하기 + +`onDownloadSuccess`의 첫 번째 인자는 목표 OTA 버전입니다. 두 번째 인자에는 다운로드 전 실행 중이던 OTA 버전인 `baseVersion`과, 최종적으로 사용한 파일 하나의 크기인 `downloadBytes`가 전달됩니다. + +```ts +onDownloadSuccess: (targetVersion, { baseVersion, downloadBytes }) => { + console.log("codepush_download_success", { + baseVersion, + targetVersion, + downloadBytes, + }); +}, +``` + +- `downloadBytes`는 바이트 단위입니다. patch 성공이면 patch 파일, full fallback이면 최종 full 파일의 크기이며 앞서 실패한 파일은 합산하지 않습니다. 번들만의 크기가 아니라 다운로드한 아카이브 전체의 크기입니다. +- 내장 번들을 실행 중이었다면 `baseVersion`은 네이티브 앱 버전(`appVersion`)입니다. + ## 오류를 기록하는 방법 오류는 `error.message`가 아니라 `error.code`로 묶어 기록하세요. diff --git a/docs/telemetry-callbacks.md b/docs/telemetry-callbacks.md index 51652c71..2c79a030 100644 --- a/docs/telemetry-callbacks.md +++ b/docs/telemetry-callbacks.md @@ -17,7 +17,7 @@ See `CodePushOptions` for the other available options. | Callback | Called when | Receives | Notes | | --- | --- | --- | --- | | `onDownloadStart` | The download of an available update begins | `(label)` | Indicates only that the download has started. | -| `onDownloadSuccess` | The download of an available update finishes | `(label)` | Installation and successful execution are not guaranteed yet. | +| `onDownloadSuccess` | The download of an available update finishes | `(label, details)` | Installation and successful execution are not guaranteed yet. | | `onUpdateArchiveResult` | A binary patch or asset diff update has finished downloading, before installation | `(label, result)` | Shows which archive was used and whether patch fallback occurred. | | `onUpdateSuccess` | An installed update runs and is confirmed successful through `notifyAppReady` | `(label)` | This is the point to record an actual update success. | | `onUpdateRollback` | An installed update fails to run and rolls back to the previous version | `(label)` | This is separate from download or installation success. | @@ -28,6 +28,24 @@ See `CodePushOptions` for the other available options. > **Note:** The type definition for `onRolloutSkipped` includes a second `error` parameter, but the runtime passes no value for it, so it is always `undefined`. +## Recording download file size by version + +`onDownloadSuccess` receives the target OTA label as its first argument and `{ baseVersion, downloadBytes }` as its second: + +```ts +onDownloadSuccess: (targetVersion, { baseVersion, downloadBytes }) => { + console.log("codepush_download_success", { + baseVersion, + targetVersion, + downloadBytes, + }); +}, +``` + +`downloadBytes` is the size in bytes of the final downloaded file used for the update, including any assets in the archive. A successful patch reports its patch file size; a full fallback reports only the full file size, excluding earlier attempts. + +`baseVersion` is the OTA label running before the download, or the native app version (`appVersion`) for the embedded bundle. + ## How to record errors Group errors by `error.code`, not `error.message`. diff --git a/ios/CodePush/CodePush.mm b/ios/CodePush/CodePush.mm index 80887c09..03030258 100644 --- a/ios/CodePush/CodePush.mm +++ b/ios/CodePush/CodePush.mm @@ -788,12 +788,14 @@ -(void)loadBundleOnTick:(NSTimer *)timer { _lastProgressEventTime = 0; } + __block long long downloadBytes = 0; [CodePushPackage downloadPackage:mutableUpdatePackage expectedBundleFileName:[bundleResourceName stringByAppendingPathExtension:bundleResourceExtension] operationQueue:_methodQueue // The download is progressing forward progressCallback:^(long long expectedContentLength, long long receivedContentLength) { + downloadBytes = receivedContentLength; // Received bytes only run backwards when a new download starts a stream of its // own - the next archive after a binary patch fallback. The pause left over // from the finished download would swallow that whole stream, so frame @@ -830,17 +832,17 @@ -(void)loadBundleOnTick:(NSTimer *)timer { return reject([NSString stringWithFormat: @"%ld", (long)err.code], err.localizedDescription, err); } + NSMutableDictionary *downloadedPackage = [newPackage mutableCopy]; + downloadedPackage[@"downloadBytes"] = @(downloadBytes); if (updateArchiveResult) { // The result describes this download rather than the update, so it is put on // what this call resolves with and nowhere else: the metadata that was // written for the update knows nothing about it, and whoever asked for the // download decides what the result is worth. - NSMutableDictionary *downloadedPackage = [newPackage mutableCopy]; downloadedPackage[UpdateArchiveResultKey] = updateArchiveResult; - return resolve(downloadedPackage); } - resolve(newPackage); + resolve(downloadedPackage); } // The download failed failCallback:^(NSError *err) { diff --git a/ios/CodePushTests/CodePushPackageTests.m b/ios/CodePushTests/CodePushPackageTests.m index b5cfe707..11fb64ac 100644 --- a/ios/CodePushTests/CodePushPackageTests.m +++ b/ios/CodePushTests/CodePushPackageTests.m @@ -21,6 +21,21 @@ + (NSString *)pathInsideFolder:(NSString *)folderPath relativePath:(NSString *)r + (NSString *)getCodePushPath; @end +// The bridge entry point is tested without starting a JavaScript runtime. +@interface CodePush (DownloadTestAccess) +- (void)downloadUpdate:(NSDictionary *)package notifyProgress:(BOOL)notifyProgress + resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +@end + +@interface CPDownloadTestModule : CodePush +@end + +@implementation CPDownloadTestModule +- (void)dispatchDownloadProgressEvent { + // Event delivery belongs to React Native; the download and its result remain real. +} +@end + static NSString *const ExpectedBundleFileName = @"main.jsbundle"; /* The metadata the install writes last, which is not part of the contents the hash is for. */ static NSString *const UpdateMetadataFileName = @"app.json"; @@ -747,6 +762,60 @@ - (void)testLeavesAFileOutsideThePackageFolderAloneWhenTheAssetDiffManifestNames [[NSFileManager defaultManager] removeItemAtPath:sentinelPath error:nil]; } +#pragma mark - Download size returned through the native module + +- (void)assertModuleDownload:(NSDictionary *)package hasFileSizeAtURL:(NSString *)archiveURL { + XCTestExpectation *finished = [self expectationWithDescription:@"native module download"]; + CPDownloadTestModule *module = [CPDownloadTestModule new]; + dispatch_queue_t queue = dispatch_queue_create("codepush.test.module-download", DISPATCH_QUEUE_SERIAL); + [module setValue:queue forKey:@"methodQueue"]; + __block NSDictionary *result = nil; + [module downloadUpdate:package notifyProgress:NO resolve:^(id value) { + result = value; + [finished fulfill]; + } reject:^(NSString *code, NSString *message, NSError *error) { + XCTFail(@"download failed: %@ %@", code, message); + [finished fulfill]; + }]; + [self waitForExpectations:@[finished] timeout:10]; + + NSDictionary *attributes = [[NSFileManager defaultManager] + attributesOfItemAtPath:[[NSURL URLWithString:archiveURL] path] error:nil]; + XCTAssertNotNil(result); + XCTAssertNotNil(attributes[NSFileSize]); + XCTAssertEqualObjects(result[@"downloadBytes"], attributes[NSFileSize]); + NSError *error = nil; + NSDictionary *metadata = [CodePushPackage getPackage:package[@"packageHash"] error:&error]; + XCTAssertNil(error); + XCTAssertNil(metadata[@"downloadBytes"]); +} + +- (void)testModuleReturnsFullFileSizeWithoutProgressSubscription { + NSString *staging = [self stageFullArchiveContents]; + NSString *fullURL = [self serveArchive:staging named:@"full.zip"]; + [self assertModuleDownload:@{ @"packageHash": CPTestFolderHash(staging), @"downloadUrl": fullURL } + hasFileSizeAtURL:fullURL]; +} + +- (void)testModuleReturnsPatchFileSizeWithoutProgressSubscription { + NSString *staging = [self stageFullArchiveContents]; + NSString *patchURL = [self serveArchive:[self stagePatchArchiveContents] named:@"patch.zip"]; + [self assertModuleDownload:@{ + @"packageHash": CPTestFolderHash(staging), + @"downloadUrl": [self urlOfMissingFileNamed:@"unused-full.zip"], + @"binaryPatchDownloadUrl": patchURL, + } hasFileSizeAtURL:patchURL]; +} + +- (void)testModuleReturnsOnlyFullFileSizeAfterFallbackWithoutProgressSubscription { + NSString *staging = [self stageFullArchiveContents]; + NSString *fullURL = [self serveArchive:staging named:@"full.zip"]; + [self assertModuleDownload:@{ + @"packageHash": CPTestFolderHash(staging), @"downloadUrl": fullURL, + @"binaryPatchDownloadUrl": [self serveBytes:CPTestBytes(@"invalid patch archive") named:@"patch.zip"], + } hasFileSizeAtURL:fullURL]; +} + #pragma mark - Reporting the download - (void)testAnnouncesEachDownloadOfAFallbackAsItsOwnProgressStream { diff --git a/src/CodePush.js b/src/CodePush.js index 361d6451..33c03b13 100644 --- a/src/CodePush.js +++ b/src/CodePush.js @@ -629,6 +629,9 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg remotePackageLabel = remotePackage?.label; const doDownloadAndInstall = async () => { + const runningPackage = await CodePush.getUpdateMetadata(CodePush.UpdateState.RUNNING); + const baseVersion = runningPackage?.label ?? (await getConfiguration("sync")).appVersion; + syncStatusChangeCallback(CodePush.SyncStatus.DOWNLOADING_PACKAGE); invokeTelemetryCallback('onDownloadStart', sharedCodePushOptions.onDownloadStart, remotePackageLabel); @@ -637,7 +640,10 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg onUpdateArchiveResult && ((result) => onUpdateArchiveResult(remotePackageLabel, result)), ); - invokeTelemetryCallback('onDownloadSuccess', sharedCodePushOptions.onDownloadSuccess, remotePackageLabel); + invokeTelemetryCallback('onDownloadSuccess', sharedCodePushOptions.onDownloadSuccess, remotePackageLabel, { + baseVersion, + downloadBytes: localPackage.downloadBytes ?? null, + }); // Determine the correct install mode based on whether the update is mandatory or not. resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode; @@ -763,8 +769,8 @@ let CodePush; * onDownloadStart: (label: string) => void | Promise | undefined, * setOnDownloadStart(onDownloadStartFunction: (label: string) => void | Promise | undefined): void, * - * onDownloadSuccess: (label: string) => void | Promise | undefined, - * setOnDownloadSuccess(onDownloadSuccessFunction: (label: string) => void | Promise | undefined): void, + * onDownloadSuccess: (label: string, details: DownloadSuccessDetails) => void | Promise | undefined, + * setOnDownloadSuccess(onDownloadSuccessFunction: (label: string, details: DownloadSuccessDetails) => void | Promise | undefined): void, * * onSyncError: (label: string, error: Error) => void | Promise | undefined, * setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | Promise | undefined): void, diff --git a/src/CodePush.test.js b/src/CodePush.test.js index f9a3621f..b91cd68e 100644 --- a/src/CodePush.test.js +++ b/src/CodePush.test.js @@ -64,12 +64,13 @@ function diffRelease() { * which only a download that had an archive to try comes back with. * @param installedPackage the CodePush update the app is running, if any. */ -function createNativeBridge({ updateArchiveResult, installedPackage, statusReport } = {}) { +function createNativeBridge({ updateArchiveResult, downloadBytes, installedPackage, statusReport } = {}) { return { addDownloadProgressListener: jest.fn(() => ({ remove: jest.fn() })), downloadUpdate: jest.fn(async (updatePackage) => ({ ...updatePackage, ...(updateArchiveResult ? { updateArchiveResult } : {}), + ...(downloadBytes !== undefined ? { downloadBytes } : {}), })), // Without an installed CodePush update, the app runs the bundle of its binary. getUpdateMetadata: jest.fn(async () => installedPackage ?? null), @@ -95,10 +96,10 @@ function createNativeBridge({ updateArchiveResult, installedPackage, statusRepor * Callback options left after the bridge setup values are removed are registered on the * decorator, the same way an app registers them. */ -function loadCodePush({ releaseHistory = {}, updateChecker, updateArchiveResult, installedPackage, statusReport, ...codePushOptions } = {}) { +function loadCodePush({ releaseHistory = {}, updateChecker, updateArchiveResult, downloadBytes, installedPackage, statusReport, ...codePushOptions } = {}) { jest.resetModules(); const CodePush = require('./CodePush'); - const nativeBridge = createNativeBridge({ updateArchiveResult, installedPackage, statusReport }); + const nativeBridge = createNativeBridge({ updateArchiveResult, downloadBytes, installedPackage, statusReport }); CodePush.setUpTestDependencies( null, @@ -573,7 +574,7 @@ describe('telemetry callback errors', () => { await Promise.resolve(); expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED); - expect(onDownloadSuccess).toHaveBeenCalledWith(LABEL); + expect(onDownloadSuccess).toHaveBeenCalledWith(LABEL, { baseVersion: BINARY_VERSION, downloadBytes: null }); expect(nativeBridge.installUpdate).toHaveBeenCalledTimes(1); }); @@ -706,3 +707,66 @@ describe('the minimum background duration an update is installed with', () => { expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(0); }); }); + +describe('download size telemetry', () => { + it('reports the full file size when starting from the embedded bundle', async () => { + const onDownloadSuccess = jest.fn(); + const { CodePush, nativeBridge } = loadCodePush({ + releaseHistory: fullOnlyRelease(), + downloadBytes: 4096, + onDownloadSuccess, + }); + + await CodePush.sync(); + + expect(onDownloadSuccess).toHaveBeenCalledWith(LABEL, { + baseVersion: BINARY_VERSION, + downloadBytes: 4096, + }); + expect(nativeBridge.installUpdate.mock.calls[0][0]).not.toHaveProperty('downloadBytes'); + }); + + it('reports the running version and final file size after a fallback', async () => { + const onDownloadSuccess = jest.fn(); + const { CodePush } = loadCodePush({ + releaseHistory: patchedRelease(), + installedPackage: { label: '1.0.0-ota.1', appVersion: BINARY_VERSION, packageHash: INSTALLED_HASH }, + updateArchiveResult: { status: 'fallback', archive: 'binary-patch', attempts: [] }, + downloadBytes: 8192, + onDownloadSuccess, + }); + + await CodePush.sync(); + + expect(onDownloadSuccess).toHaveBeenCalledWith(LABEL, { + baseVersion: '1.0.0-ota.1', + downloadBytes: 8192, + }); + }); + + it('reports only the patch file size without subscribing to progress', async () => { + const onDownloadSuccess = jest.fn(); + const { CodePush, nativeBridge } = loadCodePush({ + releaseHistory: patchedRelease(), + downloadBytes: 512, + updateArchiveResult: { status: 'applied', archive: 'binary-patch', attempts: [] }, + onDownloadSuccess, + }); + + await CodePush.sync(); + + expect(onDownloadSuccess).toHaveBeenCalledWith(LABEL, { baseVersion: BINARY_VERSION, downloadBytes: 512 }); + expect(nativeBridge.addDownloadProgressListener).not.toHaveBeenCalled(); + }); + + it('keeps the file size on a package the app downloads itself without saving it on install', async () => { + const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: fullOnlyRelease(), downloadBytes: 4096 }); + + const remotePackage = await CodePush.checkForUpdate(); + const localPackage = await remotePackage.download(); + await localPackage.install(); + + expect(localPackage.downloadBytes).toBe(4096); + expect(nativeBridge.installUpdate.mock.calls[0][0]).not.toHaveProperty('downloadBytes'); + }); +}); diff --git a/src/package-mixins.js b/src/package-mixins.js index d9701937..d046222f 100644 --- a/src/package-mixins.js +++ b/src/package-mixins.js @@ -62,6 +62,9 @@ module.exports = (NativeCodePush) => { async install(installMode = NativeCodePush.InstallMode.ON_NEXT_RESTART, minimumBackgroundDuration = 0, updateInstalledCallback) { const localPackage = this; const localPackageCopy = Object.assign({}, localPackage); // In dev mode, React Native deep freezes any object queued over the bridge + // Download size belongs to this transfer, not the persisted update metadata. It stays on + // the package the app holds, which `download()` resolved with, and is left out only here. + delete localPackageCopy.downloadBytes; await NativeCodePush.installUpdate(localPackageCopy, installMode, minimumBackgroundDuration); updateInstalledCallback && updateInstalledCallback(); if (installMode == NativeCodePush.InstallMode.IMMEDIATE) { diff --git a/typings/react-native-code-push.d.ts b/typings/react-native-code-push.d.ts index 4aac72f9..8f2ed6f3 100644 --- a/typings/react-native-code-push.d.ts +++ b/typings/react-native-code-push.d.ts @@ -237,7 +237,7 @@ export interface CodePushOptions extends SyncOptions { /** * Callback function that is called when download finished successfully. */ - onDownloadSuccess?: (label: string) => void | Promise; + onDownloadSuccess?: (label: string, details: DownloadSuccessDetails) => void | Promise; /** * Callback function that is called when sync process failed. */ @@ -248,6 +248,16 @@ export interface CodePushOptions extends SyncOptions { onRolloutSkipped?: (label: string, error: Error) => void | Promise; } +export interface DownloadSuccessDetails { + /** Running OTA label before downloading, or the native app version for the embedded bundle. */ + baseVersion: string; + + /** Final downloaded file size in bytes, excluding earlier fallback attempts. + * Null when running native code that predates download size reporting. + */ + downloadBytes: number | null; +} + export interface DownloadProgress { /** * The total number of bytes expected to be received for this download. An update @@ -263,6 +273,14 @@ export interface DownloadProgress { } export interface LocalPackage extends Package { + /** + * Size in bytes of the final file `RemotePackage.download` used, excluding earlier fallback attempts. + * Only set on the package that call resolves with: it is not persisted in update metadata, so + * `getCurrentPackage` and `getUpdateMetadata` never return it. Undefined on native code that + * predates download size reporting. + */ + downloadBytes?: number; + /** * Installs the update by saving it to the location on disk where the runtime expects to find the latest version of the app. *