@bravemobile/react-native-code-push 13.2.0 → 13.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +4 -0
  2. package/android/build.gradle +50 -16
  3. package/android/{app/src → src}/main/cpp/CMakeLists.txt +1 -1
  4. package/cli/commands/createHistoryCommand/createReleaseHistory.test.ts +2 -2
  5. package/cli/commands/releaseCommand/addToReleaseHistory.ts +6 -0
  6. package/cli/commands/releaseCommand/index.test.ts +29 -0
  7. package/cli/commands/releaseCommand/index.ts +9 -0
  8. package/cli/commands/releaseCommand/release.test.ts +28 -0
  9. package/cli/commands/releaseCommand/release.ts +2 -0
  10. package/cli/commands/updateHistoryCommand/index.test.ts +129 -0
  11. package/cli/commands/updateHistoryCommand/index.ts +28 -2
  12. package/cli/commands/updateHistoryCommand/updateReleaseHistory.ts +3 -0
  13. package/cli/dist/commands/createHistoryCommand/createReleaseHistory.test.js +2 -2
  14. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +5 -1
  15. package/cli/dist/commands/releaseCommand/index.js +7 -1
  16. package/cli/dist/commands/releaseCommand/index.test.js +22 -0
  17. package/cli/dist/commands/releaseCommand/release.js +2 -2
  18. package/cli/dist/commands/releaseCommand/release.test.js +18 -1
  19. package/cli/dist/commands/updateHistoryCommand/index.js +23 -2
  20. package/cli/dist/commands/updateHistoryCommand/index.test.js +102 -0
  21. package/cli/dist/commands/updateHistoryCommand/updateReleaseHistory.js +4 -1
  22. package/package.json +1 -5
  23. package/react-native.config.js +1 -1
  24. package/src/CodePush.js +16 -3
  25. package/src/CodePush.test.js +57 -0
  26. package/typings/react-native-code-push.d.ts +20 -0
  27. package/android/app/build.gradle +0 -58
  28. package/android/gradle.properties +0 -20
  29. package/android/settings.gradle +0 -1
  30. /package/android/{app/proguard-rules.pro → proguard-rules.pro} +0 -0
  31. /package/android/{app/src → src}/debug/AndroidManifest.xml +0 -0
  32. /package/android/{app/src → src}/main/AndroidManifest.xml +0 -0
  33. /package/android/{app/src → src}/main/cpp/binarypatch_jni.c +0 -0
  34. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/ArchiveAttemptLog.java +0 -0
  35. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/ArchiveRestoreResult.java +0 -0
  36. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePush.java +0 -0
  37. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +0 -0
  38. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushConstants.java +0 -0
  39. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushErrorCode.java +0 -0
  40. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushHttpException.java +0 -0
  41. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushIncompleteDownloadException.java +0 -0
  42. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushInstallMode.java +0 -0
  43. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushInvalidUpdateException.java +0 -0
  44. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushMalformedDataException.java +0 -0
  45. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +0 -0
  46. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushNotInitializedException.java +0 -0
  47. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushTelemetryManager.java +0 -0
  48. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushUnknownException.java +0 -0
  49. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +0 -0
  50. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushUpdateState.java +0 -0
  51. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +0 -0
  52. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/CodePushUtils.java +0 -0
  53. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/DownloadProgress.java +0 -0
  54. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/DownloadProgressCallback.java +0 -0
  55. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/DownloadProgressEventDispatcher.java +0 -0
  56. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/FileUtils.java +0 -0
  57. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/HDiffPatchNative.java +0 -0
  58. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/SettingsManager.java +0 -0
  59. /package/android/{app/src → src}/main/java/com/microsoft/codepush/react/TLSSocketFactory.java +0 -0
  60. /package/android/{app/src → src}/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +0 -0
  61. /package/android/{app/src → src}/test/java/com/microsoft/codepush/react/CodePushErrorCodeTest.java +0 -0
  62. /package/android/{app/src → src}/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java +0 -0
  63. /package/android/{app/src → src}/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +0 -0
  64. /package/android/{app/src → src}/test/java/com/microsoft/codepush/react/DownloadProgressEventDispatcherTest.java +0 -0
package/README.md CHANGED
@@ -501,6 +501,8 @@ npx code-push release --framework expo --binary-version 1.0.0 --app-version 1.0.
501
501
  > `--app-version` should be greater than `--binary-version` (SemVer comparison).
502
502
 
503
503
  - `--rollout`: The rollout percentage for the update. (0~100, inclusive)
504
+ - `--minimum-background-duration`: The number of seconds the app must have been in the background before this update is applied on resume, for `ON_NEXT_RESUME` and `ON_NEXT_SUSPEND` installs only. (whole seconds, 0 or greater)
505
+ - The value set on the release takes precedence over the `minimumBackgroundDuration` passed to `sync`, and `0` applies the update on the next resume.
504
506
 
505
507
  #### `update-history`
506
508
 
@@ -509,6 +511,8 @@ Update the release history for a specific CodePush update.
509
511
  - Use the `--mandatory` option to make the update as mandatory or optional.
510
512
  - Use the `--rollout` option to change the rollout percentage of the update. (0~100, inclusive)
511
513
  - If the rollout percentage is reduced, users who fall outside the new target will have their rollout canceled and rollback to the previous latest version.
514
+ - Use the `--minimum-background-duration` option to change how many seconds the app must have been in the background before the update is applied on resume, for `ON_NEXT_RESUME` and `ON_NEXT_SUSPEND` installs only. (whole seconds, 0 or greater)
515
+ - It can be lowered after a release has gone out - setting it to `0`, for example, applies the update on the next resume instead of waiting.
512
516
 
513
517
  **Example:**
514
518
  - Rollback the CodePush update `1.0.1` (targeting the binary app version `1.0.0`).
@@ -1,24 +1,58 @@
1
- // Top-level build file where you can add configuration options common to all sub-projects/modules.
1
+ apply plugin: "com.android.library"
2
+ apply plugin: "com.facebook.react"
2
3
 
3
- buildscript {
4
- repositories {
5
- google()
6
- mavenCentral()
4
+ def DEFAULT_COMPILE_SDK_VERSION = 26
5
+ def DEFAULT_BUILD_TOOLS_VERSION = "26.0.3"
6
+ def DEFAULT_TARGET_SDK_VERSION = 26
7
+ def DEFAULT_MIN_SDK_VERSION = 16
8
+
9
+ react {
10
+ jsRootDir = file("..")
11
+ libraryName = "RNCodePushSpec"
12
+ codegenJavaPackageName = "com.microsoft.codepush.react"
13
+ }
14
+
15
+ android {
16
+ namespace "com.microsoft.codepush.react"
17
+
18
+ compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION
19
+ buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : DEFAULT_BUILD_TOOLS_VERSION
20
+
21
+ defaultConfig {
22
+ minSdkVersion rootProject.hasProperty('minSdkVersion') ? rootProject.minSdkVersion : DEFAULT_MIN_SDK_VERSION
23
+ targetSdkVersion rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : DEFAULT_TARGET_SDK_VERSION
24
+ versionCode 1
25
+ versionName "1.0"
7
26
  }
8
- dependencies {
9
- classpath 'com.android.tools.build:gradle:1.3.0'
10
27
 
11
- // NOTE: Do not place your application dependencies here; they belong
12
- // in the individual module build.gradle files
28
+ lintOptions {
29
+ abortOnError false
13
30
  }
14
- }
15
31
 
16
- allprojects {
17
- android {
18
- namespace "com.microsoft.codepush.react"
32
+ defaultConfig {
33
+ consumerProguardFiles 'proguard-rules.pro'
19
34
  }
20
- repositories {
21
- mavenLocal()
22
- mavenCentral()
35
+
36
+ // Builds the binary patch applier. Its sources are the shared ones at the root of the
37
+ // repository, which the CMake file points at; nothing is compiled twice.
38
+ externalNativeBuild {
39
+ cmake {
40
+ path "src/main/cpp/CMakeLists.txt"
41
+ }
42
+ }
43
+
44
+ testOptions {
45
+ // The unit tests exercise plain logic, so the android framework classes they touch
46
+ // in passing (logging above all) are allowed to do nothing instead of throwing.
47
+ unitTests.returnDefaultValues = true
23
48
  }
24
49
  }
50
+
51
+ dependencies {
52
+ implementation "com.facebook.react:react-native:+"
53
+
54
+ testImplementation "junit:junit:4.13.2"
55
+ // org.json ships as stubs in the android framework jar, and the unit tests parse real
56
+ // manifests, so they need an implementation that actually parses.
57
+ testImplementation "org.json:json:20231013"
58
+ }
@@ -6,7 +6,7 @@ project(codepush-binarypatch C)
6
6
  # the other platform compile the very same files. Referencing them where they are is
7
7
  # what keeps the appliers of the platforms from drifting apart, so they are deliberately
8
8
  # not copied into this directory.
9
- set(BINARY_PATCH_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../cpp/binarypatch)
9
+ set(BINARY_PATCH_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../../cpp/binarypatch)
10
10
 
11
11
  add_library(codepush-binarypatch SHARED
12
12
  ${CMAKE_CURRENT_SOURCE_DIR}/binarypatch_jni.c
@@ -96,8 +96,8 @@ describe("staging the release history a config is handed", () => {
96
96
  };
97
97
 
98
98
  await Promise.all([
99
- updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined),
100
- updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined),
99
+ updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined, undefined),
100
+ updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined, undefined),
101
101
  ]);
102
102
 
103
103
  expect(staged.ios).toContain("ios-url");
@@ -15,6 +15,7 @@ export async function addToReleaseHistory(
15
15
  enable: boolean,
16
16
  rollout: number | undefined,
17
17
  diffPackages: Record<string, string> | undefined,
18
+ minimumBackgroundDuration: number | undefined,
18
19
  ): Promise<void> {
19
20
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
20
21
 
@@ -49,6 +50,11 @@ export async function addToReleaseHistory(
49
50
  newReleaseHistory[appVersion].rollout = rollout;
50
51
  }
51
52
 
53
+ // An entry without it leaves the wait to the sync option, so 0 has to be written.
54
+ if (typeof minimumBackgroundDuration === 'number') {
55
+ newReleaseHistory[appVersion].minimumBackgroundDuration = minimumBackgroundDuration;
56
+ }
57
+
52
58
  try {
53
59
  await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) =>
54
60
  setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier));
@@ -32,6 +32,7 @@ const ARG_INDEX = {
32
32
  onOversizedPatch: 20,
33
33
  bundleDownloader: 21,
34
34
  diffBaseCount: 22,
35
+ minimumBackgroundDuration: 23,
35
36
  } as const;
36
37
 
37
38
  /**
@@ -147,4 +148,32 @@ describe("release command options", () => {
147
148
  const { release } = await import("./release.js");
148
149
  expect(jest.mocked(release)).not.toHaveBeenCalled();
149
150
  });
151
+
152
+ it("passes the chosen minimum background duration through to the release", async () => {
153
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', '600']);
154
+
155
+ expect(args[ARG_INDEX.minimumBackgroundDuration]).toBe(600);
156
+ });
157
+
158
+ it("leaves the minimum background duration unset when the option is not given, so the sync option decides", async () => {
159
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);
160
+
161
+ expect(args[ARG_INDEX.minimumBackgroundDuration]).toBeUndefined();
162
+ });
163
+
164
+ it.each([
165
+ ['is negative', '-1'],
166
+ ['is not a number at all', 'soon'],
167
+ ])("rejects a minimum background duration that %s", async (_caseName, value) => {
168
+ jest.spyOn(console, 'error').mockImplementation(() => {});
169
+ jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
170
+ throw new Error(`process.exit(${code})`);
171
+ }) as never);
172
+
173
+ await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', value]))
174
+ .rejects.toThrow('process.exit(1)');
175
+
176
+ const { release } = await import("./release.js");
177
+ expect(jest.mocked(release)).not.toHaveBeenCalled();
178
+ });
150
179
  });
@@ -31,6 +31,7 @@ type Options = {
31
31
  binaryBundlePath?: string;
32
32
  onOversizedPatch: OversizedPatchPolicy;
33
33
  diffBaseCount: number;
34
+ minimumBackgroundDuration?: number;
34
35
  }
35
36
 
36
37
  program.command('release')
@@ -57,6 +58,7 @@ program.command('release')
57
58
  .choices(OVERSIZED_PATCH_POLICIES)
58
59
  .default(DEFAULT_OVERSIZED_PATCH_POLICY))
59
60
  .option('--diff-base-count <number>', 'how many recent releases to build asset diff archives against (0 disables). Requires `bundleDownloader` in the config file.', parseDecimalInt, DEFAULT_DIFF_BASE_COUNT)
61
+ .option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt)
60
62
  .action(async (options: Options) => {
61
63
  const config = findAndReadConfigFile(process.cwd(), options.config);
62
64
 
@@ -70,6 +72,12 @@ program.command('release')
70
72
  process.exit(1);
71
73
  }
72
74
 
75
+ if (options.minimumBackgroundDuration !== undefined
76
+ && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
77
+ console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
78
+ process.exit(1);
79
+ }
80
+
73
81
  if (options.hashCalc && !options.skipBundle) {
74
82
  console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
75
83
  process.exit(1);
@@ -102,6 +110,7 @@ program.command('release')
102
110
  options.onOversizedPatch,
103
111
  config.bundleDownloader,
104
112
  options.diffBaseCount,
113
+ options.minimumBackgroundDuration,
105
114
  )
106
115
 
107
116
  console.log('🚀 Release completed.')
@@ -142,6 +142,7 @@ type ReleaseOverrides = {
142
142
  releaseHistory?: ReleaseHistoryInterface;
143
143
  bundleDownloader?: CliConfigInterface['bundleDownloader'];
144
144
  diffBaseCount?: number;
145
+ minimumBackgroundDuration?: number;
145
146
  };
146
147
 
147
148
  async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}) {
@@ -176,6 +177,7 @@ async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}
176
177
  overrides.onOversizedPatch,
177
178
  overrides.bundleDownloader,
178
179
  overrides.diffBaseCount,
180
+ overrides.minimumBackgroundDuration,
179
181
  );
180
182
 
181
183
  return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
@@ -829,3 +831,29 @@ describe("release with asset diff bases", () => {
829
831
  expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
830
832
  });
831
833
  });
834
+
835
+ describe("release --minimum-background-duration", () => {
836
+ it("records the background wait on the release it publishes", async () => {
837
+ const staged = await stageBundleOutput("minimum-background-duration");
838
+
839
+ const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 600 });
840
+
841
+ expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(600);
842
+ });
843
+
844
+ it("releases a background wait of zero seconds as zero, not as an unset option", async () => {
845
+ const staged = await stageBundleOutput("zero-minimum-background-duration");
846
+
847
+ const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 0 });
848
+
849
+ expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
850
+ });
851
+
852
+ it("leaves the release saying nothing about the background wait when the option is not given", async () => {
853
+ const staged = await stageBundleOutput("no-minimum-background-duration");
854
+
855
+ const { releaseHistories } = await runRelease(staged);
856
+
857
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
858
+ });
859
+ });
@@ -49,6 +49,7 @@ export async function release(
49
49
  onOversizedPatch: OversizedPatchPolicy = DEFAULT_OVERSIZED_PATCH_POLICY,
50
50
  bundleDownloader?: CliConfigInterface['bundleDownloader'],
51
51
  diffBaseCount: number = DEFAULT_DIFF_BASE_COUNT,
52
+ minimumBackgroundDuration?: number,
52
53
  ): Promise<void> {
53
54
  if (baseBundlePath) {
54
55
  // Checked before the bundler runs, so the wrong base bundle costs a second rather
@@ -167,6 +168,7 @@ export async function release(
167
168
  enable,
168
169
  rollout,
169
170
  Object.keys(diffPackages).length > 0 ? diffPackages : undefined,
171
+ minimumBackgroundDuration,
170
172
  )
171
173
 
172
174
  if (!skipCleanup) {
@@ -0,0 +1,129 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
4
+ import type { CliConfigInterface, ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";
5
+
6
+ /**
7
+ * Checks the command definition against the entry it saves. Everything this command does
8
+ * ends up in the release history the config is handed, so an option that never reaches it
9
+ * leaves the release exactly as it was - which the command still reports as a success.
10
+ */
11
+
12
+ const BINARY_VERSION = '1.0.0';
13
+ const APP_VERSION = '1.0.1';
14
+
15
+ let mockConfig: CliConfigInterface;
16
+
17
+ jest.mock("../../utils/fsUtils.js", () => ({
18
+ findAndReadConfigFile: () => mockConfig,
19
+ }));
20
+
21
+ /**
22
+ * Puts one released version in the config's history and records every history it is
23
+ * handed back, so a case can read the entry as the consumer would store it.
24
+ */
25
+ function stageReleaseHistory(): ReleaseHistoryInterface[] {
26
+ const releaseHistory: ReleaseHistoryInterface = {
27
+ [APP_VERSION]: {
28
+ enabled: true,
29
+ mandatory: false,
30
+ downloadUrl: 'https://cdn.example.com/bundle',
31
+ packageHash: 'a3f1c0',
32
+ },
33
+ };
34
+ const saved: ReleaseHistoryInterface[] = [];
35
+
36
+ mockConfig = {
37
+ bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
38
+ getReleaseHistory: async () => releaseHistory,
39
+ // The command edits the history in place, so what it saved is copied out here.
40
+ setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
41
+ saved.push(structuredClone(releaseInfo));
42
+ },
43
+ };
44
+
45
+ return saved;
46
+ }
47
+
48
+ /**
49
+ * Parses an `update-history` invocation against the real command definition. Commander is
50
+ * asked to throw instead of exiting, and to keep its diagnostics to itself, so a rejected
51
+ * option can be asserted on without ending the worker or the output.
52
+ */
53
+ async function parseUpdateHistoryCommand(args: string[]): Promise<void> {
54
+ const { program } = await import("commander");
55
+ await import("./index.js");
56
+
57
+ const updateHistoryCommand = program.commands.find((command) => command.name() === 'update-history');
58
+ updateHistoryCommand?.exitOverride();
59
+ updateHistoryCommand?.configureOutput({ writeErr: () => {} });
60
+
61
+ await program.parseAsync(['update-history', ...args], { from: 'user' });
62
+ }
63
+
64
+ async function runUpdateHistoryCommand(args: string[]): Promise<void> {
65
+ await parseUpdateHistoryCommand(['-b', BINARY_VERSION, '-v', APP_VERSION, ...args]);
66
+ }
67
+
68
+ let saved: ReleaseHistoryInterface[];
69
+
70
+ beforeEach(() => {
71
+ jest.resetModules();
72
+ saved = stageReleaseHistory();
73
+ jest.spyOn(console, 'log').mockImplementation(() => {});
74
+ jest.spyOn(console, 'error').mockImplementation(() => {});
75
+ jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
76
+ throw new Error(`process.exit(${code})`);
77
+ }) as never);
78
+ });
79
+
80
+ afterEach(() => {
81
+ jest.restoreAllMocks();
82
+ // The command writes its JSON under the directory it was invoked in.
83
+ fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true });
84
+ });
85
+
86
+ describe("update-history command options", () => {
87
+ it("lowers the background wait of a release that is already out to zero seconds", async () => {
88
+ await runUpdateHistoryCommand(['--minimum-background-duration', '0']);
89
+
90
+ expect(saved).toHaveLength(1);
91
+ expect(saved[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
92
+ });
93
+
94
+ it("leaves the entry saying nothing about the background wait when only --enable is given", async () => {
95
+ await runUpdateHistoryCommand(['--enable', 'false']);
96
+
97
+ expect(saved[0][APP_VERSION].enabled).toBe(false);
98
+ expect(saved[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
99
+ });
100
+
101
+ it("saves the rollout percentage when --rollout is the only option given", async () => {
102
+ await runUpdateHistoryCommand(['--rollout', '50']);
103
+
104
+ expect(saved[0][APP_VERSION].rollout).toBe(50);
105
+ });
106
+
107
+ it("exits without saving anything when no option says what to change", async () => {
108
+ await expect(runUpdateHistoryCommand([])).rejects.toThrow('process.exit(1)');
109
+
110
+ expect(saved).toHaveLength(0);
111
+ });
112
+
113
+ it("rejects a negative background wait and saves nothing", async () => {
114
+ await expect(runUpdateHistoryCommand(['--minimum-background-duration', '-1']))
115
+ .rejects.toThrow('process.exit(1)');
116
+
117
+ expect(saved).toHaveLength(0);
118
+ });
119
+
120
+ it.each([
121
+ ['a percentage above 100', '150'],
122
+ ['a percentage that is not a number', 'abc'],
123
+ ])("rejects %s and saves nothing", async (_scenario, rollout) => {
124
+ await expect(runUpdateHistoryCommand(['--rollout', rollout]))
125
+ .rejects.toThrow('process.exit(1)');
126
+
127
+ expect(saved).toHaveLength(0);
128
+ });
129
+ });
@@ -12,6 +12,7 @@ type Options = {
12
12
  mandatory?: boolean;
13
13
  enable?: boolean;
14
14
  rollout?: number;
15
+ minimumBackgroundDuration?: number;
15
16
  }
16
17
 
17
18
  program.command('update-history')
@@ -24,14 +25,32 @@ program.command('update-history')
24
25
  .option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, undefined)
25
26
  .option('-e, --enable <bool>', 'make the release to be enabled', parseBoolean, undefined)
26
27
  .option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, undefined)
28
+ .option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt, undefined)
27
29
  .action(async (options: Options) => {
28
30
  const config = findAndReadConfigFile(process.cwd(), options.config);
29
31
 
30
- if (typeof options.mandatory !== "boolean" && typeof options.enable !== "boolean") {
32
+ if (typeof options.mandatory !== "boolean"
33
+ && typeof options.enable !== "boolean"
34
+ && typeof options.rollout !== "number"
35
+ && typeof options.minimumBackgroundDuration !== "number") {
31
36
  console.error('No options specified. Exiting the program.')
32
37
  process.exit(1)
33
38
  }
34
39
 
40
+ // `Number.isFinite` is what rejects a non-numeric `--rollout`: `parseFloat` turns it into
41
+ // NaN, and both `NaN < 0` and `NaN > 100` are false.
42
+ if (options.rollout !== undefined
43
+ && (!Number.isFinite(options.rollout) || options.rollout < 0 || options.rollout > 100)) {
44
+ console.error('Rollout percentage number must be between 0 and 100 (inclusive).');
45
+ process.exit(1);
46
+ }
47
+
48
+ if (options.minimumBackgroundDuration !== undefined
49
+ && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
50
+ console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
51
+ process.exit(1);
52
+ }
53
+
35
54
  await updateReleaseHistory(
36
55
  options.appVersion,
37
56
  options.binaryVersion,
@@ -41,10 +60,17 @@ program.command('update-history')
41
60
  options.identifier,
42
61
  options.mandatory,
43
62
  options.enable,
44
- options.rollout
63
+ options.rollout,
64
+ options.minimumBackgroundDuration
45
65
  )
46
66
  });
47
67
 
68
+ // Not `parseInt` itself: commander hands a coercion function the current value as its
69
+ // second argument, which `parseInt` reads as the radix.
70
+ function parseDecimalInt(value: string): number {
71
+ return parseInt(value, 10);
72
+ }
73
+
48
74
  function parseBoolean(value: string) {
49
75
  if (value === 'true') return true;
50
76
  if (value === 'false') return false;
@@ -11,6 +11,7 @@ export async function updateReleaseHistory(
11
11
  mandatory: boolean | undefined,
12
12
  enable: boolean | undefined,
13
13
  rollout: number | undefined,
14
+ minimumBackgroundDuration: number | undefined,
14
15
  ): Promise<void> {
15
16
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
16
17
 
@@ -20,6 +21,8 @@ export async function updateReleaseHistory(
20
21
  if (typeof mandatory === "boolean") updateInfo.mandatory = mandatory;
21
22
  if (typeof enable === "boolean") updateInfo.enabled = enable;
22
23
  if (typeof rollout === "number") updateInfo.rollout = rollout;
24
+ // 0 is what lowers the wait to "apply on the next resume", so truthiness cannot decide here.
25
+ if (typeof minimumBackgroundDuration === "number") updateInfo.minimumBackgroundDuration = minimumBackgroundDuration;
23
26
 
24
27
  try {
25
28
  await stageReleaseHistoryFile(binaryVersion, releaseHistory, platform, (jsonFilePath) =>
@@ -68,8 +68,8 @@ describe("staging the release history a config is handed", () => {
68
68
  staged[platform] = fs.readFileSync(jsonFilePath, "utf8");
69
69
  };
70
70
  await Promise.all([
71
- updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined),
72
- updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined),
71
+ updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined, undefined),
72
+ updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined, undefined),
73
73
  ]);
74
74
  expect(staged.ios).toContain("ios-url");
75
75
  expect(staged.ios).not.toContain("android-url");
@@ -1,5 +1,5 @@
1
1
  import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js";
2
- export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownloadUrl, binaryPatchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, diffPackages) {
2
+ export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownloadUrl, binaryPatchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, diffPackages, minimumBackgroundDuration) {
3
3
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
4
4
  const updateInfo = releaseHistory[appVersion];
5
5
  if (updateInfo) {
@@ -26,6 +26,10 @@ export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownl
26
26
  if (typeof rollout === 'number') {
27
27
  newReleaseHistory[appVersion].rollout = rollout;
28
28
  }
29
+ // An entry without it leaves the wait to the sync option, so 0 has to be written.
30
+ if (typeof minimumBackgroundDuration === 'number') {
31
+ newReleaseHistory[appVersion].minimumBackgroundDuration = minimumBackgroundDuration;
32
+ }
29
33
  try {
30
34
  await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) => setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier));
31
35
  }
@@ -27,6 +27,7 @@ program.command('release')
27
27
  .choices(OVERSIZED_PATCH_POLICIES)
28
28
  .default(DEFAULT_OVERSIZED_PATCH_POLICY))
29
29
  .option('--diff-base-count <number>', 'how many recent releases to build asset diff archives against (0 disables). Requires `bundleDownloader` in the config file.', parseDecimalInt, DEFAULT_DIFF_BASE_COUNT)
30
+ .option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt)
30
31
  .action(async (options) => {
31
32
  const config = findAndReadConfigFile(process.cwd(), options.config);
32
33
  if (typeof options.rollout === 'number' && (options.rollout < 0 || options.rollout > 100)) {
@@ -37,13 +38,18 @@ program.command('release')
37
38
  console.error('--diff-base-count must be a whole number of releases, 0 or greater.');
38
39
  process.exit(1);
39
40
  }
41
+ if (options.minimumBackgroundDuration !== undefined
42
+ && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
43
+ console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
44
+ process.exit(1);
45
+ }
40
46
  if (options.hashCalc && !options.skipBundle) {
41
47
  console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
42
48
  process.exit(1);
43
49
  }
44
50
  // Resolved before bundling so a wrong path fails now rather than after a build.
45
51
  const baseBundlePath = resolveBinaryBundlePathOption(options.binaryBundlePath);
46
- await release(config.bundleUploader, config.getReleaseHistory, config.setReleaseHistory, options.binaryVersion, options.appVersion, options.framework, options.platform, options.identifier, options.outputPath, options.entryFile, options.jsBundleName, options.mandatory, options.enable, options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, options.outputMetroDir, options.hashCalc, baseBundlePath, options.onOversizedPatch, config.bundleDownloader, options.diffBaseCount);
52
+ await release(config.bundleUploader, config.getReleaseHistory, config.setReleaseHistory, options.binaryVersion, options.appVersion, options.framework, options.platform, options.identifier, options.outputPath, options.entryFile, options.jsBundleName, options.mandatory, options.enable, options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, options.outputMetroDir, options.hashCalc, baseBundlePath, options.onOversizedPatch, config.bundleDownloader, options.diffBaseCount, options.minimumBackgroundDuration);
47
53
  console.log('🚀 Release completed.');
48
54
  });
49
55
  // Not `parseInt` itself: commander hands a coercion function the current value as its
@@ -29,6 +29,7 @@ const ARG_INDEX = {
29
29
  onOversizedPatch: 20,
30
30
  bundleDownloader: 21,
31
31
  diffBaseCount: 22,
32
+ minimumBackgroundDuration: 23,
32
33
  };
33
34
  /**
34
35
  * Parses a `release` invocation against the real command definition. Commander is asked
@@ -118,4 +119,25 @@ describe("release command options", () => {
118
119
  const { release } = await import("./release.js");
119
120
  expect(jest.mocked(release)).not.toHaveBeenCalled();
120
121
  });
122
+ it("passes the chosen minimum background duration through to the release", async () => {
123
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', '600']);
124
+ expect(args[ARG_INDEX.minimumBackgroundDuration]).toBe(600);
125
+ });
126
+ it("leaves the minimum background duration unset when the option is not given, so the sync option decides", async () => {
127
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);
128
+ expect(args[ARG_INDEX.minimumBackgroundDuration]).toBeUndefined();
129
+ });
130
+ it.each([
131
+ ['is negative', '-1'],
132
+ ['is not a number at all', 'soon'],
133
+ ])("rejects a minimum background duration that %s", async (_caseName, value) => {
134
+ jest.spyOn(console, 'error').mockImplementation(() => { });
135
+ jest.spyOn(process, 'exit').mockImplementation(((code) => {
136
+ throw new Error(`process.exit(${code})`);
137
+ }));
138
+ await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', value]))
139
+ .rejects.toThrow('process.exit(1)');
140
+ const { release } = await import("./release.js");
141
+ expect(jest.mocked(release)).not.toHaveBeenCalled();
142
+ });
121
143
  });
@@ -9,7 +9,7 @@ import { resolveAssetDiffBases } from "../../functions/resolveAssetDiffBases.js"
9
9
  import { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, DEFAULT_OVERSIZED_PATCH_POLICY, extractCodePushBundleContents, formatBinaryPatchSummary, hashBundleFile, isPatchArchiveOversized, makeBinaryPatchBundle, readBinaryPatchBaseRecord, } from "../../functions/makeBinaryPatchBundle.js";
10
10
  /** How many of the most recent releases a release builds asset diff archives against. */
11
11
  export const DEFAULT_DIFF_BASE_COUNT = 3;
12
- export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc, baseBundlePath, onOversizedPatch = DEFAULT_OVERSIZED_PATCH_POLICY, bundleDownloader, diffBaseCount = DEFAULT_DIFF_BASE_COUNT) {
12
+ export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc, baseBundlePath, onOversizedPatch = DEFAULT_OVERSIZED_PATCH_POLICY, bundleDownloader, diffBaseCount = DEFAULT_DIFF_BASE_COUNT, minimumBackgroundDuration) {
13
13
  if (baseBundlePath) {
14
14
  // Checked before the bundler runs, so the wrong base bundle costs a second rather
15
15
  // than a full build.
@@ -92,7 +92,7 @@ export async function release(bundleUploader, getReleaseHistory, setReleaseHisto
92
92
  `(${error instanceof Error ? error.message : String(error)}).`);
93
93
  }
94
94
  }
95
- await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, Object.keys(diffPackages).length > 0 ? diffPackages : undefined);
95
+ await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, Object.keys(diffPackages).length > 0 ? diffPackages : undefined, minimumBackgroundDuration);
96
96
  if (!skipCleanup) {
97
97
  cleanUpOutputs(outputPath);
98
98
  }
@@ -95,7 +95,7 @@ async function runRelease(staged, overrides = {}) {
95
95
  await release(recordingUploader(uploads, overrides.uploadFailsFor), history.getReleaseHistory, history.setReleaseHistory, BINARY_VERSION, APP_VERSION, undefined, overrides.platform ?? 'ios', undefined, staged.outputPath, 'index.ts',
96
96
  // Left unset unless the case is about -j, the way commander leaves it.
97
97
  overrides.jsBundleName, false, true, undefined, true, // skipBundle
98
- overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount);
98
+ overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount, overrides.minimumBackgroundDuration);
99
99
  return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
100
100
  }
101
101
  let logs;
@@ -563,3 +563,20 @@ describe("release with asset diff bases", () => {
563
563
  expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
564
564
  });
565
565
  });
566
+ describe("release --minimum-background-duration", () => {
567
+ it("records the background wait on the release it publishes", async () => {
568
+ const staged = await stageBundleOutput("minimum-background-duration");
569
+ const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 600 });
570
+ expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(600);
571
+ });
572
+ it("releases a background wait of zero seconds as zero, not as an unset option", async () => {
573
+ const staged = await stageBundleOutput("zero-minimum-background-duration");
574
+ const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 0 });
575
+ expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
576
+ });
577
+ it("leaves the release saying nothing about the background wait when the option is not given", async () => {
578
+ const staged = await stageBundleOutput("no-minimum-background-duration");
579
+ const { releaseHistories } = await runRelease(staged);
580
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
581
+ });
582
+ });
@@ -12,14 +12,35 @@ program.command('update-history')
12
12
  .option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, undefined)
13
13
  .option('-e, --enable <bool>', 'make the release to be enabled', parseBoolean, undefined)
14
14
  .option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, undefined)
15
+ .option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt, undefined)
15
16
  .action(async (options) => {
16
17
  const config = findAndReadConfigFile(process.cwd(), options.config);
17
- if (typeof options.mandatory !== "boolean" && typeof options.enable !== "boolean") {
18
+ if (typeof options.mandatory !== "boolean"
19
+ && typeof options.enable !== "boolean"
20
+ && typeof options.rollout !== "number"
21
+ && typeof options.minimumBackgroundDuration !== "number") {
18
22
  console.error('No options specified. Exiting the program.');
19
23
  process.exit(1);
20
24
  }
21
- await updateReleaseHistory(options.appVersion, options.binaryVersion, config.getReleaseHistory, config.setReleaseHistory, options.platform, options.identifier, options.mandatory, options.enable, options.rollout);
25
+ // `Number.isFinite` is what rejects a non-numeric `--rollout`: `parseFloat` turns it into
26
+ // NaN, and both `NaN < 0` and `NaN > 100` are false.
27
+ if (options.rollout !== undefined
28
+ && (!Number.isFinite(options.rollout) || options.rollout < 0 || options.rollout > 100)) {
29
+ console.error('Rollout percentage number must be between 0 and 100 (inclusive).');
30
+ process.exit(1);
31
+ }
32
+ if (options.minimumBackgroundDuration !== undefined
33
+ && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
34
+ console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
35
+ process.exit(1);
36
+ }
37
+ await updateReleaseHistory(options.appVersion, options.binaryVersion, config.getReleaseHistory, config.setReleaseHistory, options.platform, options.identifier, options.mandatory, options.enable, options.rollout, options.minimumBackgroundDuration);
22
38
  });
39
+ // Not `parseInt` itself: commander hands a coercion function the current value as its
40
+ // second argument, which `parseInt` reads as the radix.
41
+ function parseDecimalInt(value) {
42
+ return parseInt(value, 10);
43
+ }
23
44
  function parseBoolean(value) {
24
45
  if (value === 'true')
25
46
  return true;
@@ -0,0 +1,102 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
4
+ /**
5
+ * Checks the command definition against the entry it saves. Everything this command does
6
+ * ends up in the release history the config is handed, so an option that never reaches it
7
+ * leaves the release exactly as it was - which the command still reports as a success.
8
+ */
9
+ const BINARY_VERSION = '1.0.0';
10
+ const APP_VERSION = '1.0.1';
11
+ let mockConfig;
12
+ jest.mock("../../utils/fsUtils.js", () => ({
13
+ findAndReadConfigFile: () => mockConfig,
14
+ }));
15
+ /**
16
+ * Puts one released version in the config's history and records every history it is
17
+ * handed back, so a case can read the entry as the consumer would store it.
18
+ */
19
+ function stageReleaseHistory() {
20
+ const releaseHistory = {
21
+ [APP_VERSION]: {
22
+ enabled: true,
23
+ mandatory: false,
24
+ downloadUrl: 'https://cdn.example.com/bundle',
25
+ packageHash: 'a3f1c0',
26
+ },
27
+ };
28
+ const saved = [];
29
+ mockConfig = {
30
+ bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
31
+ getReleaseHistory: async () => releaseHistory,
32
+ // The command edits the history in place, so what it saved is copied out here.
33
+ setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
34
+ saved.push(structuredClone(releaseInfo));
35
+ },
36
+ };
37
+ return saved;
38
+ }
39
+ /**
40
+ * Parses an `update-history` invocation against the real command definition. Commander is
41
+ * asked to throw instead of exiting, and to keep its diagnostics to itself, so a rejected
42
+ * option can be asserted on without ending the worker or the output.
43
+ */
44
+ async function parseUpdateHistoryCommand(args) {
45
+ const { program } = await import("commander");
46
+ await import("./index.js");
47
+ const updateHistoryCommand = program.commands.find((command) => command.name() === 'update-history');
48
+ updateHistoryCommand?.exitOverride();
49
+ updateHistoryCommand?.configureOutput({ writeErr: () => { } });
50
+ await program.parseAsync(['update-history', ...args], { from: 'user' });
51
+ }
52
+ async function runUpdateHistoryCommand(args) {
53
+ await parseUpdateHistoryCommand(['-b', BINARY_VERSION, '-v', APP_VERSION, ...args]);
54
+ }
55
+ let saved;
56
+ beforeEach(() => {
57
+ jest.resetModules();
58
+ saved = stageReleaseHistory();
59
+ jest.spyOn(console, 'log').mockImplementation(() => { });
60
+ jest.spyOn(console, 'error').mockImplementation(() => { });
61
+ jest.spyOn(process, 'exit').mockImplementation(((code) => {
62
+ throw new Error(`process.exit(${code})`);
63
+ }));
64
+ });
65
+ afterEach(() => {
66
+ jest.restoreAllMocks();
67
+ // The command writes its JSON under the directory it was invoked in.
68
+ fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true });
69
+ });
70
+ describe("update-history command options", () => {
71
+ it("lowers the background wait of a release that is already out to zero seconds", async () => {
72
+ await runUpdateHistoryCommand(['--minimum-background-duration', '0']);
73
+ expect(saved).toHaveLength(1);
74
+ expect(saved[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
75
+ });
76
+ it("leaves the entry saying nothing about the background wait when only --enable is given", async () => {
77
+ await runUpdateHistoryCommand(['--enable', 'false']);
78
+ expect(saved[0][APP_VERSION].enabled).toBe(false);
79
+ expect(saved[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
80
+ });
81
+ it("saves the rollout percentage when --rollout is the only option given", async () => {
82
+ await runUpdateHistoryCommand(['--rollout', '50']);
83
+ expect(saved[0][APP_VERSION].rollout).toBe(50);
84
+ });
85
+ it("exits without saving anything when no option says what to change", async () => {
86
+ await expect(runUpdateHistoryCommand([])).rejects.toThrow('process.exit(1)');
87
+ expect(saved).toHaveLength(0);
88
+ });
89
+ it("rejects a negative background wait and saves nothing", async () => {
90
+ await expect(runUpdateHistoryCommand(['--minimum-background-duration', '-1']))
91
+ .rejects.toThrow('process.exit(1)');
92
+ expect(saved).toHaveLength(0);
93
+ });
94
+ it.each([
95
+ ['a percentage above 100', '150'],
96
+ ['a percentage that is not a number', 'abc'],
97
+ ])("rejects %s and saves nothing", async (_scenario, rollout) => {
98
+ await expect(runUpdateHistoryCommand(['--rollout', rollout]))
99
+ .rejects.toThrow('process.exit(1)');
100
+ expect(saved).toHaveLength(0);
101
+ });
102
+ });
@@ -1,5 +1,5 @@
1
1
  import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js";
2
- export async function updateReleaseHistory(appVersion, binaryVersion, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout) {
2
+ export async function updateReleaseHistory(appVersion, binaryVersion, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, minimumBackgroundDuration) {
3
3
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
4
4
  const updateInfo = releaseHistory[appVersion];
5
5
  if (!updateInfo)
@@ -10,6 +10,9 @@ export async function updateReleaseHistory(appVersion, binaryVersion, getRelease
10
10
  updateInfo.enabled = enable;
11
11
  if (typeof rollout === "number")
12
12
  updateInfo.rollout = rollout;
13
+ // 0 is what lowers the wait to "apply on the next resume", so truthiness cannot decide here.
14
+ if (typeof minimumBackgroundDuration === "number")
15
+ updateInfo.minimumBackgroundDuration = minimumBackgroundDuration;
13
16
  try {
14
17
  await stageReleaseHistoryFile(binaryVersion, releaseHistory, platform, (jsonFilePath) => setReleaseHistory(binaryVersion, jsonFilePath, releaseHistory, platform, identifier));
15
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bravemobile/react-native-code-push",
3
- "version": "13.2.0",
3
+ "version": "13.3.1",
4
4
  "description": "React Native plugin for the CodePush service",
5
5
  "main": "src/CodePush.js",
6
6
  "react-native": "src/CodePush.js",
@@ -58,10 +58,6 @@
58
58
  "!ios/build",
59
59
  "!ios/CodePushTests",
60
60
  "!android/build",
61
- "!android/app/build",
62
- "!android/gradle",
63
- "!android/gradlew",
64
- "!android/gradlew.bat",
65
61
  "!android/local.properties",
66
62
  "!cpp/binarypatch/host",
67
63
  "!**/__tests__",
@@ -5,7 +5,7 @@ module.exports = {
5
5
  packageImportPath: "import com.microsoft.codepush.react.CodePush;",
6
6
  packageInstance:
7
7
  "CodePush.getInstance(getApplicationContext(), BuildConfig.DEBUG)",
8
- sourceDir: './android/app',
8
+ sourceDir: './android',
9
9
  libraryName: 'RNCodePushSpec',
10
10
  cmakeListsPath: 'build/generated/source/codegen/jni/CMakeLists.txt',
11
11
  }
package/src/CodePush.js CHANGED
@@ -200,6 +200,11 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
200
200
  * back on when the diff fails on its asset side.
201
201
  */
202
202
  asset_diff_download_url: diffPackageDownloadUrl,
203
+ /**
204
+ * Only present when the release asked for a background duration of its own, which
205
+ * then wins over the one the `sync` call passes.
206
+ */
207
+ minimum_background_duration: latestReleaseInfo.minimumBackgroundDuration,
203
208
  // (`enabled` will always be true in the release information obtained from the previous process.)
204
209
  is_available: latestReleaseInfo.enabled,
205
210
  package_hash: latestReleaseInfo.packageHash,
@@ -309,6 +314,11 @@ function mapToRemotePackageMetadata(updateInfo) {
309
314
  ...(updateInfo.asset_diff_download_url
310
315
  ? { assetDiffDownloadUrl: updateInfo.asset_diff_download_url }
311
316
  : {}),
317
+ // Presence is read off the type, not off truthiness, so that a release asking for `0`
318
+ // seconds keeps its own value instead of looking like a release that asked for nothing.
319
+ ...(typeof updateInfo.minimum_background_duration === 'number'
320
+ ? { minimumBackgroundDuration: updateInfo.minimum_background_duration }
321
+ : {}),
312
322
  };
313
323
  }
314
324
 
@@ -552,6 +562,7 @@ const sync = (() => {
552
562
  */
553
563
  async function syncInternal(options = {}, syncStatusChangeCallback, downloadProgressCallback, handleBinaryVersionMismatchCallback) {
554
564
  let resolvedInstallMode;
565
+ let resolvedMinimumBackgroundDuration;
555
566
  const syncOptions = {
556
567
  deploymentKey: null,
557
568
  ignoreFailedUpdates: true,
@@ -596,8 +607,8 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
596
607
  if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
597
608
  log("Update is installed and will be run on the next app restart.");
598
609
  } else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
599
- if (syncOptions.minimumBackgroundDuration > 0) {
600
- log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
610
+ if (resolvedMinimumBackgroundDuration > 0) {
611
+ log(`Update is installed and will be run after the app has been in the background for at least ${resolvedMinimumBackgroundDuration} seconds.`);
601
612
  } else {
602
613
  log("Update is installed and will be run when the app next resumes.");
603
614
  }
@@ -630,9 +641,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
630
641
 
631
642
  // Determine the correct install mode based on whether the update is mandatory or not.
632
643
  resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode;
644
+ // `??` rather than `||`, so a release asking for `0` seconds keeps its own value.
645
+ resolvedMinimumBackgroundDuration = remotePackage.minimumBackgroundDuration ?? syncOptions.minimumBackgroundDuration;
633
646
 
634
647
  syncStatusChangeCallback(CodePush.SyncStatus.INSTALLING_UPDATE);
635
- await localPackage.install(resolvedInstallMode, syncOptions.minimumBackgroundDuration, () => {
648
+ await localPackage.install(resolvedInstallMode, resolvedMinimumBackgroundDuration, () => {
636
649
  syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED);
637
650
  });
638
651
 
@@ -649,3 +649,60 @@ describe('telemetry callback errors', () => {
649
649
  expect(nativeBridge.saveStatusReportForRetry).not.toHaveBeenCalled();
650
650
  });
651
651
  });
652
+
653
+ /**
654
+ * A release can ask for its own minimum background duration - how long the app has to have
655
+ * been in the background before an update installed with `ON_NEXT_RESUME` is applied. The
656
+ * app passes a duration to `sync` for the releases that ask for nothing, and whichever of
657
+ * the two wins is what the native module is given when the update is installed.
658
+ */
659
+ describe('the minimum background duration an update is installed with', () => {
660
+ /** The release the CLI writes when the release carries its own background duration. */
661
+ function releaseAskingFor(minimumBackgroundDuration) {
662
+ const history = fullOnlyRelease();
663
+ history[LABEL].minimumBackgroundDuration = minimumBackgroundDuration;
664
+ return history;
665
+ }
666
+
667
+ /** The background duration the native module is given, in seconds. */
668
+ function installedMinimumBackgroundDuration(nativeBridge) {
669
+ expect(nativeBridge.installUpdate).toHaveBeenCalledTimes(1);
670
+ return nativeBridge.installUpdate.mock.calls[0][2];
671
+ }
672
+
673
+ it('takes the one the release history entry asks for over the one the sync call passes', async () => {
674
+ const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: releaseAskingFor(600) });
675
+
676
+ const syncStatus = await CodePush.sync({
677
+ installMode: InstallMode.ON_NEXT_RESUME,
678
+ minimumBackgroundDuration: 3600,
679
+ });
680
+
681
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
682
+ expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(600);
683
+ });
684
+
685
+ it('keeps the one the sync call passes when the release history entry asks for none', async () => {
686
+ const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: fullOnlyRelease() });
687
+
688
+ const syncStatus = await CodePush.sync({
689
+ installMode: InstallMode.ON_NEXT_RESUME,
690
+ minimumBackgroundDuration: 3600,
691
+ });
692
+
693
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
694
+ expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(3600);
695
+ });
696
+
697
+ it('runs a release that asks for zero seconds on the next resume, however long the sync call would have waited', async () => {
698
+ const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: releaseAskingFor(0) });
699
+
700
+ const syncStatus = await CodePush.sync({
701
+ installMode: InstallMode.ON_NEXT_RESUME,
702
+ minimumBackgroundDuration: 3600,
703
+ });
704
+
705
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
706
+ expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(0);
707
+ });
708
+ });
@@ -44,6 +44,13 @@ export interface ReleaseInfo {
44
44
  */
45
45
  diffPackages?: Record<string, string>;
46
46
  packageHash: string;
47
+ /**
48
+ * The minimum number of seconds the app has to have been in the background before this
49
+ * release is applied, with the same meaning as `SyncOptions.minimumBackgroundDuration`.
50
+ * When it is present it takes precedence over the value passed to `sync`, so a single
51
+ * release can be applied sooner or later than the app asks for by default.
52
+ */
53
+ minimumBackgroundDuration?: number;
47
54
  rollout?: number;
48
55
  }
49
56
 
@@ -73,6 +80,11 @@ export interface UpdateCheckResponse {
73
80
  should_run_binary_version?: boolean;
74
81
  update_app_version?: boolean;
75
82
  is_mandatory?: boolean;
83
+ /**
84
+ * The minimum number of seconds the app has to have been in the background before this
85
+ * update is applied. It is only present when the release asked for one of its own.
86
+ */
87
+ minimum_background_duration?: number;
76
88
  }
77
89
 
78
90
  /**
@@ -345,6 +357,13 @@ export interface RemotePackage extends Package {
345
357
  * `UpdateArchiveResult`.
346
358
  */
347
359
  assetDiffDownloadUrl?: string;
360
+
361
+ /**
362
+ * The minimum number of seconds the app has to have been in the background before this
363
+ * update is applied. It is only present when the release history entry set it, and it
364
+ * then takes precedence over the `minimumBackgroundDuration` passed to `sync`.
365
+ */
366
+ minimumBackgroundDuration?: number;
348
367
  }
349
368
 
350
369
  export interface SyncOptions {
@@ -372,6 +391,7 @@ export interface SyncOptions {
372
391
  * only applies to updates which are installed using `InstallMode.ON_NEXT_RESUME` or `InstallMode.ON_NEXT_SUSPEND`, and can be useful
373
392
  * for getting your update in front of end users sooner, without being too obtrusive. Defaults to `0`, which has the effect of applying
374
393
  * the update immediately after a resume or unless the app suspension is long enough to not matter, regardless how long it was in the background.
394
+ * A `minimumBackgroundDuration` set on the release history entry of the update being installed takes precedence over this option.
375
395
  */
376
396
  minimumBackgroundDuration?: number;
377
397
 
@@ -1,58 +0,0 @@
1
- apply plugin: "com.android.library"
2
- apply plugin: "com.facebook.react"
3
-
4
- def DEFAULT_COMPILE_SDK_VERSION = 26
5
- def DEFAULT_BUILD_TOOLS_VERSION = "26.0.3"
6
- def DEFAULT_TARGET_SDK_VERSION = 26
7
- def DEFAULT_MIN_SDK_VERSION = 16
8
-
9
- react {
10
- jsRootDir = file("../..")
11
- libraryName = "RNCodePushSpec"
12
- codegenJavaPackageName = "com.microsoft.codepush.react"
13
- }
14
-
15
- android {
16
- namespace "com.microsoft.codepush.react"
17
-
18
- compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION
19
- buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : DEFAULT_BUILD_TOOLS_VERSION
20
-
21
- defaultConfig {
22
- minSdkVersion rootProject.hasProperty('minSdkVersion') ? rootProject.minSdkVersion : DEFAULT_MIN_SDK_VERSION
23
- targetSdkVersion rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : DEFAULT_TARGET_SDK_VERSION
24
- versionCode 1
25
- versionName "1.0"
26
- }
27
-
28
- lintOptions {
29
- abortOnError false
30
- }
31
-
32
- defaultConfig {
33
- consumerProguardFiles 'proguard-rules.pro'
34
- }
35
-
36
- // Builds the binary patch applier. Its sources are the shared ones at the root of the
37
- // repository, which the CMake file points at; nothing is compiled twice.
38
- externalNativeBuild {
39
- cmake {
40
- path "src/main/cpp/CMakeLists.txt"
41
- }
42
- }
43
-
44
- testOptions {
45
- // The unit tests exercise plain logic, so the android framework classes they touch
46
- // in passing (logging above all) are allowed to do nothing instead of throwing.
47
- unitTests.returnDefaultValues = true
48
- }
49
- }
50
-
51
- dependencies {
52
- implementation "com.facebook.react:react-native:+"
53
-
54
- testImplementation "junit:junit:4.13.2"
55
- // org.json ships as stubs in the android framework jar, and the unit tests parse real
56
- // manifests, so they need an implementation that actually parses.
57
- testImplementation "org.json:json:20231013"
58
- }
@@ -1,20 +0,0 @@
1
- # Project-wide Gradle settings.
2
-
3
- # IDE (e.g. Android Studio) users:
4
- # Gradle settings configured through the IDE *will override*
5
- # any settings specified in this file.
6
-
7
- # For more details on how to configure your build environment visit
8
- # http://www.gradle.org/docs/current/userguide/build_environment.html
9
-
10
- # Specifies the JVM arguments used for the daemon process.
11
- # The setting is particularly useful for tweaking memory settings.
12
- # Default value: -Xmx10248m -XX:MaxPermSize=256m
13
- # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
14
-
15
- # When configured, Gradle will run in incubating parallel mode.
16
- # This option should only be used with decoupled projects. More details, visit
17
- # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
18
- # org.gradle.parallel=true
19
-
20
- android.useDeprecatedNdk=true
@@ -1 +0,0 @@
1
- include ':app'