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
42 changes: 25 additions & 17 deletions CodePush.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { AcquisitionManager as Sdk, DownloadStatus } from "./lib/commonjs/acquisition-sdk/acquisition-sdk";
import { createCodePushProvider } from "./lib/commonjs/react/code-push-provider";
import { useCodePush } from "./lib/commonjs/react/use-code-push";
import { useDownloadProgress } from "./lib/commonjs/react/use-download-progress";
import { createUpdateStore } from "./lib/commonjs/update-state/update-store";
import { trackDownload, trackInstall } from "./lib/commonjs/update-state/track-update";
import { onceUntilRejected } from "./lib/commonjs/once-until-rejected";
import { packageMetadata } from "./lib/commonjs/package-metadata";
import { Alert } from "./AlertAdapter";
import requestFetchAdapter from "./request-fetch-adapter";
import { AppState, NativeEventEmitter, Platform } from "react-native";
Expand Down Expand Up @@ -30,20 +33,20 @@ async function withTimeout(promise, timeoutMs) {

// Downloads a remote package, augmenting it with a bound install() method
// beyond what's included in the metadata sent by the server.
async function downloadUpdate(remotePackage, downloadProgressCallback, reportStatusDownload) {
function downloadUpdate(remotePackage, downloadProgressCallback, reportStatusDownload) {
return trackDownload(updateStore, () => downloadUpdateInternal(remotePackage, downloadProgressCallback, reportStatusDownload));
}

async function downloadUpdateInternal(remotePackage, downloadProgressCallback, reportStatusDownload) {
if (!remotePackage.downloadUrl) {
throw new Error("Cannot download an update without a download url");
}

let downloadProgressSubscription;
if (downloadProgressCallback) {
const codePushEventEmitter = new NativeEventEmitter(NativeCodePush);
// Use event subscription to obtain download progress.
downloadProgressSubscription = codePushEventEmitter.addListener(
"CodePushDownloadProgress",
downloadProgressCallback
);
}
const codePushEventEmitter = new NativeEventEmitter(NativeCodePush);
const downloadProgressSubscription = codePushEventEmitter.addListener("CodePushDownloadProgress", (progress) => {
updateStore.dispatch({ type: "downloadProgressed", progress });
downloadProgressCallback && downloadProgressCallback(progress);
});

log.info(`Downloading update. ${logFields({ ...packageLogFields(remotePackage), packageSize: remotePackage.packageSize, url: redactUrl(remotePackage.downloadUrl) })}`);
const downloadStartTime = Date.now();
Expand All @@ -61,12 +64,12 @@ async function downloadUpdate(remotePackage, downloadProgressCallback, reportSta
// Use the downloaded package info. Native code will save the package info
// so that the client knows what the current package version is.
try {
const updatePackageCopy = Object.assign({}, remotePackage);
Object.keys(updatePackageCopy).forEach((key) => (typeof updatePackageCopy[key] === 'function') && delete updatePackageCopy[key]);
// Functions cannot cross the bridge.
const updatePackageCopy = packageMetadata(remotePackage);

let downloadedPackage;
try {
downloadedPackage = await NativeCodePush.downloadUpdate(updatePackageCopy, !!downloadProgressCallback);
downloadedPackage = await NativeCodePush.downloadUpdate(updatePackageCopy, true);
} catch (err) {
await reportDownloadStatus(DownloadStatus.Failed, undefined, err?.userInfo?.updateType);
throw err;
Expand All @@ -78,7 +81,7 @@ async function downloadUpdate(remotePackage, downloadProgressCallback, reportSta

return attachLocalPackageMethods({ ...downloadedPackage, isPending: false }); // A freshly downloaded package hasn't been installed yet
} finally {
downloadProgressSubscription && downloadProgressSubscription.remove();
downloadProgressSubscription.remove();
}
}

Expand Down Expand Up @@ -246,9 +249,8 @@ async function getCurrentPackage() {
async function installUpdate(localPackage, installMode = NativeCodePush.codePushInstallModeOnNextRestart, minimumBackgroundDuration = 0, updateInstalledCallback) {
const localPackageCopy = Object.assign({}, localPackage); // In dev mode, React Native deep freezes any object queued over the bridge
log.info(`Installing update. ${logFields({ ...packageLogFields(localPackage), installMode: installModeName(installMode), minimumBackgroundDuration })}`);
await NativeCodePush.installUpdate(localPackageCopy, installMode, minimumBackgroundDuration);
await trackInstall(updateStore, localPackage, () => NativeCodePush.installUpdate(localPackageCopy, installMode, minimumBackgroundDuration));
log.info(`Installed update. ${installedUpdateNextStep(installMode, minimumBackgroundDuration)}`);
updateStore.dispatch({ type: "updateInstalled", packageHash: localPackage.packageHash });
updateInstalledCallback && updateInstalledCallback();
if (installMode == NativeCodePush.codePushInstallModeImmediate) {
NativeCodePush.restartApp(false);
Expand Down Expand Up @@ -480,6 +482,11 @@ function setUpTestDependencies(testSdk, providedTestConfig, testNativeBridge) {
if (testNativeBridge) NativeCodePush = testNativeBridge;
}

function clearUpdates() {
NativeCodePush.clearUpdates();
updateStore.dispatch({ type: "updatesCleared" });
}

async function restartApp(onlyIfUpdateIsPending = false) {
NativeCodePush.restartApp(onlyIfUpdateIsPending);
}
Expand Down Expand Up @@ -762,7 +769,7 @@ if (NativeCodePush) {
sync,
disallowRestart: NativeCodePush.disallow,
allowRestart: NativeCodePush.allow,
clearUpdates: NativeCodePush.clearUpdates,
clearUpdates,
InstallMode: {
IMMEDIATE: NativeCodePush.codePushInstallModeImmediate, // Restart the app immediately
ON_NEXT_RESTART: NativeCodePush.codePushInstallModeOnNextRestart, // Don't artificially restart the app. Allow the update to be "picked up" on the next app restart
Expand Down Expand Up @@ -836,4 +843,5 @@ if (NativeCodePush) {
},
});
module.exports.useCodePush = useCodePush;
module.exports.useDownloadProgress = useDownloadProgress;
}
28 changes: 25 additions & 3 deletions docs/api-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,16 @@ If a prop changes after the first render, the next check on resume and the next
```tsx
import { useCodePush } from "@bitrise/code-push-sdk";

const { isChecking, isUpdateAvailable, availableUpdate, incompatibleUpdate, checkError, checkForUpdate, sync, restartApp } = useCodePush();
const {
isChecking, isUpdateAvailable, availableUpdate, incompatibleUpdate, checkError,
isDownloading, installError, pendingUpdate,
checkForUpdate, sync, restartApp,
} = useCodePush();
```

Returns the update state, and re-renders the component when it changes. Call it in any component inside [`CodePushProvider`](#codepushprovider). It throws outside the Provider, because without the Provider nothing calls [`notifyAppReady`](#codepushnotifyappready), and every update rolls back.

The state shows every update check: the Provider's, and those of [`checkForUpdate`](#codepushcheckforupdate), [`sync`](#codepushsync) and the `codePush()` higher-order component. It starts empty each time the app starts.
The state shows every update check, download and install: the Provider's, and those of [`checkForUpdate`](#codepushcheckforupdate), [`sync`](#codepushsync), [`RemotePackage.download`](#remotepackage), [`LocalPackage.install`](#localpackage) and the `codePush()` higher-order component. It starts empty each time the app starts.

* __isChecking__ *(Boolean)* - A check for an update runs.

Expand All @@ -85,13 +89,19 @@ The state shows every update check: the Provider's, and those of [`checkForUpdat

* __checkError__ *(Error)* - Why the last check failed. A new check clears it.

* __isDownloading__ *(Boolean)* - An update downloads. Read its progress with [`useDownloadProgress`](#usedownloadprogress).

* __installError__ *(Error)* - Why the last attempt to install an update failed, in its download or in its install. A new download, or a successful install, clears it. The update stays in `availableUpdate`, so the user can try again.

* __pendingUpdate__ *([LocalPackage](#localpackage), without `install`)* - An update that this app session installed, but that runs only after the app restarts. Show a "Restart to update" prompt, and apply it with `restartApp(true)`. An install with `InstallMode.IMMEDIATE` also sets it, for the time until the restart (longer, while [`disallowRestart`](#codepushdisallowrestart) is in effect).

* __checkForUpdate__ *(options?: { deploymentKey?: String })* - Like [`codePush.checkForUpdate`](#codepushcheckforupdate).

* __sync__ *(options?: [SyncOptions](#codepushoptions))* - Like [`codePush.sync`](#codepushsync).

* __restartApp__ *(onlyIfUpdateIsPending?)* - The same as [`codePush.restartApp`](#codepushrestartapp).

The `sync` and `checkForUpdate` of the hook use the props of the Provider, such as `deploymentKey`, for every option that you do not pass. They take no callbacks. If you need a status or progress callback, call `codePush.sync` directly: its checks show in the state too.
The `sync` and `checkForUpdate` of the hook use the props of the Provider, such as `deploymentKey`, for every option that you do not pass. They take no callbacks: the state reports the check, the download and the install. If you still need a status callback, call `codePush.sync` directly: it updates the state too.

Example: a "Check for updates" button on a settings screen.

Expand All @@ -116,6 +126,18 @@ function CheckForUpdatesButton() {
}
```

#### useDownloadProgress

```tsx
import { useDownloadProgress } from "@bitrise/code-push-sdk";

const progress = useDownloadProgress(); // { receivedBytes, totalBytes } or undefined
```

Returns the progress (`{ receivedBytes, totalBytes }`) of the running download, or `undefined` before the first progress event of a download, and after a download fails. After a successful download, it keeps the last progress until the next download starts. Check `isDownloading` of [`useCodePush`](#usecodepush) to know whether a download runs. `totalBytes` is -1 when the server does not send the size, for example for a compressed response: show a spinner instead of a percentage then.

It is separate from `useCodePush`, because it changes many times per second during a download: only the components that call it re-render that often. Like `useCodePush`, it throws outside [`CodePushProvider`](#codepushprovider).

#### codePush

You can also call [`sync`](#codepushsync) from a `useEffect` in your root component instead of using [`CodePushProvider`](#codepushprovider). Below are some examples of ways you can set this up (you can pick one or even use a combination):
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/package-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as assert from 'assert';

import { packageMetadata } from '../package-metadata';

describe('packageMetadata', () => {
it('copies every value but the functions, and leaves the package as it was', () => {
const download = () => Promise.resolve();
const pkg = { label: 'v2', packageSize: 0, isMandatory: false, description: '', download };

assert.deepStrictEqual(packageMetadata(pkg), {
label: 'v2',
packageSize: 0,
isMandatory: false,
description: '',
});
assert.strictEqual(pkg.download, download);
});
});
8 changes: 8 additions & 0 deletions src/package-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type PackageMetadata<T> = Omit<T, 'download' | 'install'>;

/** A copy of a package without its bound methods, `download()` and `install()`. */
export function packageMetadata<T extends object>(pkg: T): PackageMetadata<T> {
return Object.fromEntries(
Object.entries(pkg).filter(([, value]) => typeof value !== 'function'),
) as PackageMetadata<T>;
}
2 changes: 1 addition & 1 deletion src/react/store-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type UpdateActions = Pick<UseCodePushResult, 'checkForUpdate' | 'sync' |
// The actions travel through the context, not an import, because they are implemented in the root CodePush.js, which
// src/ cannot import.
export interface CodePushContextValue {
store: Pick<UpdateStore, 'getSnapshot' | 'subscribe'>;
store: Pick<UpdateStore, 'getSnapshot' | 'getDownloadProgress' | 'subscribe'>;
actions: UpdateActions;
}

Expand Down
14 changes: 14 additions & 0 deletions src/react/use-download-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useContext, useSyncExternalStore } from 'react';

import type { DownloadProgress } from '../../typings/react-native-code-push';
import { StoreContext } from './store-context';

// Separate from useCodePush(), because progress changes up to once per frame during a download. Only the components
// that show it re-render that often.
export function useDownloadProgress(): DownloadProgress | undefined {
const context = useContext(StoreContext);
if (!context) {
throw new Error('useDownloadProgress must be used inside <CodePushProvider>.');
}
return useSyncExternalStore(context.store.subscribe, context.store.getDownloadProgress);
}
63 changes: 63 additions & 0 deletions src/update-state/__tests__/track-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as assert from 'assert';

import type { LocalPackage } from '../../../typings/react-native-code-push';
import { trackDownload, trackInstall } from '../track-update';
import type { UpdateEvent } from '../update-state';

function recorder() {
const events: UpdateEvent[] = [];
return { events, store: { dispatch: (event: UpdateEvent) => events.push(event) } };
}

describe('trackDownload', () => {
it('reports a download that completes, and returns its result', async () => {
const { events, store } = recorder();

assert.strictEqual(await trackDownload(store, async () => 'package'), 'package');
assert.deepStrictEqual(
events.map((e) => e.type),
['downloadStarted', 'downloadCompleted'],
);
});

it('reports a download that fails, and rethrows its error', async () => {
const { events, store } = recorder();
const error = new Error('network');

await assert.rejects(
trackDownload(store, () => Promise.reject(error)),
error,
);
assert.deepStrictEqual(events, [{ type: 'downloadStarted' }, { type: 'downloadFailed', error }]);
});
});

describe('trackInstall', () => {
const localPackage = {
label: 'v2',
packageHash: 'hash',
isPending: false,
install: () => Promise.resolve(),
} as unknown as LocalPackage;

it('reports a pending copy of the package, without its methods', async () => {
const { events, store } = recorder();

await trackInstall(store, localPackage, () => Promise.resolve());

assert.deepStrictEqual(events, [
{ type: 'updateInstalled', update: { label: 'v2', packageHash: 'hash', isPending: true } },
]);
});

it('reports a failed install, and rethrows its error', async () => {
const { events, store } = recorder();
const error = new Error('disk');

await assert.rejects(
trackInstall(store, localPackage, () => Promise.reject(error)),
error,
);
assert.deepStrictEqual(events, [{ type: 'installFailed', error }]);
});
});
Loading
Loading