diff --git a/.github/actions/dart_package/action.yaml b/.github/actions/dart_package/action.yaml index 6c9f016b..a1aa2193 100644 --- a/.github/actions/dart_package/action.yaml +++ b/.github/actions/dart_package/action.yaml @@ -81,7 +81,7 @@ runs: run: echo "package_name=${PACKAGE_PATH##*/}" >> $GITHUB_OUTPUT - name: 📊 Upload Coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: flags: ${{ steps.split.outputs.package_name }} token: ${{ inputs.codecov_token }} diff --git a/.github/actions/verify_version/action.yaml b/.github/actions/verify_version/action.yaml index edf6ced8..42cc2784 100644 --- a/.github/actions/verify_version/action.yaml +++ b/.github/actions/verify_version/action.yaml @@ -12,7 +12,7 @@ runs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🎯 Setup Dart uses: dart-lang/setup-dart@v1 diff --git a/.github/workflows/_shorebird_ci_dart.yaml b/.github/workflows/_shorebird_ci_dart.yaml index e71c309d..2a8a9b08 100644 --- a/.github/workflows/_shorebird_ci_dart.yaml +++ b/.github/workflows/_shorebird_ci_dart.yaml @@ -30,7 +30,7 @@ jobs: ci: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive - name: 🐳 Run Redis @@ -65,7 +65,7 @@ jobs: dart test --coverage=coverage && \ dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore - if: inputs.has_unit_tests - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: flags: ${{ inputs.package_name }} working-directory: ${{ inputs.package_path }} diff --git a/.github/workflows/deploy_artifact_proxy_dev.yaml b/.github/workflows/deploy_artifact_proxy_dev.yaml index 3ff1b158..94e48ad1 100644 --- a/.github/workflows/deploy_artifact_proxy_dev.yaml +++ b/.github/workflows/deploy_artifact_proxy_dev.yaml @@ -27,7 +27,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive diff --git a/.github/workflows/deploy_artifact_proxy_prod.yaml b/.github/workflows/deploy_artifact_proxy_prod.yaml index edd022fd..8218e442 100644 --- a/.github/workflows/deploy_artifact_proxy_prod.yaml +++ b/.github/workflows/deploy_artifact_proxy_prod.yaml @@ -21,7 +21,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive diff --git a/.github/workflows/deploy_discord_gcp_alerts.yaml b/.github/workflows/deploy_discord_gcp_alerts.yaml index c94cef67..439e6b7c 100644 --- a/.github/workflows/deploy_discord_gcp_alerts.yaml +++ b/.github/workflows/deploy_discord_gcp_alerts.yaml @@ -27,7 +27,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 700aedea..1788a087 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -61,7 +61,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🖥️ Add Shorebird to PATH shell: bash @@ -98,7 +98,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: 🤖 AVD Cache - uses: actions/cache@v5 + uses: actions/cache@v6 id: avd-cache with: path: | @@ -143,7 +143,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ matrix.branch }} diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 57d6face..5f746fe0 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 👀 Check Licenses uses: ./.github/actions/license_check @@ -46,7 +46,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: dorny/paths-filter@v4 name: Build Detection @@ -154,7 +154,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🎯 Build ${{ matrix.package }} uses: ./.github/actions/dart_package @@ -178,7 +178,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🎯 Build ${{ matrix.package }} uses: ./.github/actions/dart_package @@ -203,7 +203,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🐳 Run Redis run: | @@ -231,7 +231,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🔎 Verify ${{ matrix.package }} uses: ./.github/actions/verify_version diff --git a/.github/workflows/publish_dart_package.yaml b/.github/workflows/publish_dart_package.yaml index 31e62f57..ac1c0c9d 100644 --- a/.github/workflows/publish_dart_package.yaml +++ b/.github/workflows/publish_dart_package.yaml @@ -31,7 +31,7 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🐳 Run Redis if: inputs.needs_redis diff --git a/.github/workflows/publish_scoped_deps.yaml b/.github/workflows/publish_scoped_deps.yaml deleted file mode 100644 index c08321ab..00000000 --- a/.github/workflows/publish_scoped_deps.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: Publish scoped_deps - -on: - push: - tags: - - "scoped_deps-v[0-9]+.[0-9]+.[0-9]+*" - -jobs: - publish: - uses: ./.github/workflows/publish_dart_package.yaml - with: - working_directory: packages/scoped_deps - tag_prefix: scoped_deps-v - permissions: - id-token: write diff --git a/.github/workflows/shorebird_ci.yaml b/.github/workflows/shorebird_ci.yaml index 0a7be55a..6cce1801 100644 --- a/.github/workflows/shorebird_ci.yaml +++ b/.github/workflows/shorebird_ci.yaml @@ -28,7 +28,7 @@ jobs: shorebird_redis_client: ${{ steps.filter.outputs.shorebird_redis_client }} stripe_api: ${{ steps.filter.outputs.stripe_api }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: # Full history so dorny/paths-filter can diff on push events. fetch-depth: 0 @@ -221,7 +221,7 @@ jobs: name: CSpell runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive - uses: streetsidesoftware/cspell-action@v8 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 899bb574..ce78e9b9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,9 +1,68 @@ # Release Notes +## 1.6.115 (July 28, 2026) + +- 🐦 Flutter 3.44.8 / Dart 3.12.2 support + - Android: fix a startup crash on vendor-modified devices caused by the + accessibility bridge failing during initialization. + - iOS/macOS: fix framework builds failing under Xcode 27 due to a `lipo` + architecture verification error. + +## 1.6.114 (July 20, 2026) + +- 🐦 Flutter 3.44.7 / Dart 3.12.2 support + - Android: fix a file descriptor leak that could crash apps using external + textures (e.g. video) on some GPUs such as Arm Mali. + +## 1.6.113 (July 10, 2026) + +- 🐛 Fix `--shorebird-trace` build tracing being silently disabled even on + Flutter versions that support it, caused by probing plain `flutter build -h` + output where the flag is hidden. + +## 1.6.112 (July 10, 2026) + +- 🐦 Flutter 3.44.6 / Dart 3.12.2 support + - Linux: fix a crash when building asset bundles with native assets enabled + but without a native app build directory (missing `CMakeCache.txt`). + - Android: fix a crash when running instrumented tests. + +## 1.6.111 (July 7, 2026) + +- 🐦 Flutter 3.44.5 / Dart 3.12.2 support + - Android: fix `libapp.so` sometimes missing from the APK or app bundle when + building with flavors, or with older app templates combined with certain + plugins. + - Impeller: fix text shadows rendering in the wrong position, separate from + the text. + - Impeller (Vulkan): fix a crash during app shutdown or rotation on some + Android devices. + - Android: silence spurious Built-in Kotlin migration warnings when building + with AGP below 9. +- 🐛 Updater: report the currently running patch number instead of the last + booted patch. + +## 1.6.110 (June 25, 2026) + +- 🐦 Flutter 3.44.4 / Dart 3.12.2 support + - Linux: fix bounds checking in `FlAccessibleTextField`. + +## 1.6.109 (June 22, 2026) + +- 🐦 Flutter 3.44.3 / Dart 3.12.2 support + - Fix a potential buffer overflow in the animated PNG decoder when parsing + malformed fdAT chunks. + - Swift Package Manager: fix crashes from concurrent directory/file/symlink + creation when building iOS or macOS apps. + - Impeller (OpenGL ES): fix a texture resource leak by ensuring the + TextureGLES destructor releases all objects it holds, including its sync + fence. + - Print a trace when skipping flavor-specific and platform-specific assets. + ## 1.6.108 (June 12, 2026) - 🐦 Flutter 3.44.2 / Dart 3.12.2 support diff --git a/bin/internal/flutter.version b/bin/internal/flutter.version index 2b8beb90..d14bc61e 100644 --- a/bin/internal/flutter.version +++ b/bin/internal/flutter.version @@ -1 +1 @@ -65b1e4368e98a85ea11e8ef2cffc286c3b2ad8b2 \ No newline at end of file +c15ef6379403a0a55531a058bdb2c8e55bc05c98 diff --git a/packages/artifact_proxy/pubspec.yaml b/packages/artifact_proxy/pubspec.yaml index 4a52bed7..91d29d43 100644 --- a/packages/artifact_proxy/pubspec.yaml +++ b/packages/artifact_proxy/pubspec.yaml @@ -21,4 +21,4 @@ dev_dependencies: build_runner: ^2.15.0 json_serializable: ^6.13.2 mocktail: ^1.0.5 - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/dex/pubspec.yaml b/packages/dex/pubspec.yaml index 4044f8c5..d321c95c 100644 --- a/packages/dex/pubspec.yaml +++ b/packages/dex/pubspec.yaml @@ -8,8 +8,8 @@ environment: sdk: ">=3.9.0 <4.0.0" dependencies: - meta: ^1.16.0 + meta: ^1.18.3 dev_dependencies: path: ^1.9.1 - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/discord_gcp_alerts/pubspec.yaml b/packages/discord_gcp_alerts/pubspec.yaml index 7c0969cb..24574085 100644 --- a/packages/discord_gcp_alerts/pubspec.yaml +++ b/packages/discord_gcp_alerts/pubspec.yaml @@ -17,4 +17,4 @@ dev_dependencies: build_runner: ^2.15.0 json_serializable: ^6.13.2 mocktail: ^1.0.5 - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/flutter_version_resolver/pubspec.yaml b/packages/flutter_version_resolver/pubspec.yaml index e305d066..b3392575 100644 --- a/packages/flutter_version_resolver/pubspec.yaml +++ b/packages/flutter_version_resolver/pubspec.yaml @@ -15,4 +15,4 @@ dependencies: dev_dependencies: mocktail: ^1.0.5 - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/jwt/pubspec.yaml b/packages/jwt/pubspec.yaml index 046daf53..86235edc 100644 --- a/packages/jwt/pubspec.yaml +++ b/packages/jwt/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: collection: ^1.18.0 http: ^1.5.0 json_annotation: ^4.12.0 - meta: ^1.7.0 + meta: ^1.18.3 pointycastle: ^4.0.0 rsa_pkcs: ^2.0.0 ttl_cache: ^0.3.0 @@ -21,4 +21,4 @@ dev_dependencies: build_runner: ^2.15.0 json_serializable: ^6.13.2 path: ^1.9.0 - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/redis_client/pubspec.yaml b/packages/redis_client/pubspec.yaml index 91894912..aa02c419 100644 --- a/packages/redis_client/pubspec.yaml +++ b/packages/redis_client/pubspec.yaml @@ -13,4 +13,4 @@ dependencies: resp_client: ^1.2.0 dev_dependencies: - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/scoped_deps/README.md b/packages/scoped_deps/README.md index 1eff1f1b..24c0e894 100644 --- a/packages/scoped_deps/README.md +++ b/packages/scoped_deps/README.md @@ -1,7 +1,30 @@ + + # Scoped Deps A simple dependency injection library built on Zones. +## Status: vendored, no longer published by Shorebird + +Felix Angelov wrote this package in 2023 (originally `package:scoped`) so that +our packages could share one dependency-injection mechanism. We published it to +pub.dev in 2024 in case it was useful to others, then never needed to change it +again. + +In 2026, Morgan Hunt ([`mrgnhnt96/scoped_deps`][mrgnhnt_repo]) approached us +about taking over the pub.dev package and continuing to improve it, and we +accepted. **[`scoped_deps` on pub.dev][pub_package] is now his**, and its +versions have diverged from this directory. + +Shorebird packages keep using this vendored copy, resolved through the Dart +workspace rather than from pub.dev. The code hasn't meaningfully changed in two +years, so there's nothing to gain from moving right now. At some point we should +look at where the pub.dev package has evolved to and consider deleting this copy +in favor of it. + +[mrgnhnt_repo]: https://github.com/mrgnhnt96/scoped_deps +[pub_package]: https://pub.dev/packages/scoped_deps + ## Quick Start ```dart diff --git a/packages/scoped_deps/pubspec.yaml b/packages/scoped_deps/pubspec.yaml index 1145e73a..40a3ca08 100644 --- a/packages/scoped_deps/pubspec.yaml +++ b/packages/scoped_deps/pubspec.yaml @@ -1,16 +1,19 @@ +# cspell:words mrgnhnt name: scoped_deps description: A simple Dart library for managing scoped dependencies built on top of Zones from dart:async. version: 0.1.0+2 homepage: https://git.tonycloud.org/flutter/shorebird-workspace repository: https://git.tonycloud.org/flutter/shorebird-workspace resolution: workspace +# pub.dev/packages/scoped_deps is now owned by mrgnhnt96; see README.md. +publish_to: none environment: sdk: ">=3.9.0 <4.0.0" dependencies: - meta: ^1.0.0 + meta: ^1.18.3 dev_dependencies: mocktail: ^1.0.5 - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/shorebird_build_trace/pubspec.yaml b/packages/shorebird_build_trace/pubspec.yaml index deb1a805..ddae7827 100644 --- a/packages/shorebird_build_trace/pubspec.yaml +++ b/packages/shorebird_build_trace/pubspec.yaml @@ -13,5 +13,5 @@ environment: sdk: ^3.9.0 dev_dependencies: - test: ^1.31.1 - very_good_analysis: ^10.0.0 + test: ^1.31.2 + very_good_analysis: ^10.3.0 diff --git a/packages/shorebird_ci/pubspec.yaml b/packages/shorebird_ci/pubspec.yaml index a270f9e6..43e95ee5 100644 --- a/packages/shorebird_ci/pubspec.yaml +++ b/packages/shorebird_ci/pubspec.yaml @@ -17,11 +17,11 @@ environment: dependencies: args: ^2.6.0 - meta: ^1.16.0 + meta: ^1.18.3 path: ^1.9.0 pub_semver: ^2.1.0 yaml: ^3.1.0 dev_dependencies: - test: ^1.31.1 - very_good_analysis: ^10.2.0 + test: ^1.31.2 + very_good_analysis: ^10.3.0 diff --git a/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart b/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart index e4db14bf..fc9f68b5 100644 --- a/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart +++ b/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart @@ -125,13 +125,20 @@ ${link(uri: Uri.parse(openShorebirdIssueUrl))} /// Probes the command's help output and caches the result per [command] /// so that subsequent calls for the same command are free. /// Returns `false` if the help check fails for any reason. + /// + /// The probe uses verbose help (`-h -v`): `--shorebird-trace` is registered + /// with `hide: !verboseHelp` in Flutter, so it does not appear in plain + /// `-h` output. Probing with `-h` alone therefore returned `false` even on + /// Flutter versions that fully support the flag, silently disabling build + /// tracing for everyone. `-h -v` lists hidden options so support is + /// detected correctly. Future _supportsTraceFlag(String command) async { if (_traceSupport.containsKey(command)) return _traceSupport[command]!; try { final result = await process.run( 'flutter', - ['build', command, '-h'], + ['build', command, '-h', '-v'], runInShell: false, ); final supported = result.stdout.toString().contains('--shorebird-trace'); diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index a478b83f..183f5f5b 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -129,6 +129,22 @@ If left checked, Xcode will rewrite the build number in the uploaded IPA, so the throw ProcessExit(ExitCode.software.code); } + // When code signing is requested (the default), `flutter build ipa` is + // expected to export a signed .ipa. Flutter treats the export step as + // optional and exits 0 even when it fails (e.g. no signing certificate), + // so we must verify the .ipa was actually produced. Otherwise we would + // report a successful release and point the user at an .ipa that does not + // exist. See https://github.com/shorebirdtech/shorebird/issues/3807. + if (codesign && artifactManager.getIpa() == null) { + logger.err( + ''' +Unable to find generated IPA. This usually means that the IPA export step of "flutter build ipa" failed (for example, due to a missing or invalid code signing certificate). Review the build output above for the underlying error. + +If you do not need a signed IPA (for example, you will sign the .xcarchive in Xcode), re-run this command with --no-codesign.''', + ); + throw ProcessExit(ExitCode.software.code); + } + return xcarchiveDirectory; } diff --git a/packages/shorebird_cli/lib/src/version.dart b/packages/shorebird_cli/lib/src/version.dart index b9c9c062..36a07d21 100644 --- a/packages/shorebird_cli/lib/src/version.dart +++ b/packages/shorebird_cli/lib/src/version.dart @@ -1,2 +1,2 @@ // Generated code. Do not modify. -const packageVersion = '1.6.108'; +const packageVersion = '1.6.115'; diff --git a/packages/shorebird_cli/pubspec.yaml b/packages/shorebird_cli/pubspec.yaml index 0d2a7134..8f871c28 100644 --- a/packages/shorebird_cli/pubspec.yaml +++ b/packages/shorebird_cli/pubspec.yaml @@ -1,6 +1,6 @@ name: shorebird_cli description: Command-line tool to interact with Shorebird's services. -version: 1.6.108 +version: 1.6.115 repository: https://git.tonycloud.org/flutter/shorebird-workspace resolution: workspace @@ -20,24 +20,24 @@ dependencies: crypto: ^3.0.6 dex: path: ../dex - equatable: ^2.0.8 - googleapis_auth: ^2.3.1 + equatable: ^2.1.0 + googleapis_auth: ^2.3.2 http: ^1.5.0 - intl: ^0.20.2 + intl: ^0.20.3 io: ^1.0.5 json_annotation: ^4.12.0 json_path: ^0.9.0 jwt: path: ../jwt mason_logger: ^0.3.5 - meta: ^1.16.0 + meta: ^1.18.3 open_aot_patch_tools: path: ../open_aot_patch_tools path: ^1.9.1 pem: ^2.0.5 platform: ^3.1.6 pointycastle: ^4.0.0 - propertylistserialization: ^1.4.1 + propertylistserialization: ^1.5.0 pub_semver: ^2.2.0 pubspec_parse: ^1.5.0 retry: ^3.1.2 @@ -50,7 +50,7 @@ dependencies: path: ../shorebird_code_push_protocol stream_transform: ^2.1.1 uuid: ^4.5.3 - xml: ^6.5.0 + xml: ^7.0.1 yaml: ^3.1.3 yaml_edit: ^2.2.4 @@ -60,7 +60,7 @@ dev_dependencies: build_version: ^2.1.1 json_serializable: ^6.13.2 mocktail: ^1.0.5 - test: ^1.31.1 + test: ^1.31.2 executables: shorebird: diff --git a/packages/shorebird_cli/test/src/artifact_builder/artifact_builder_test.dart b/packages/shorebird_cli/test/src/artifact_builder/artifact_builder_test.dart index 62920bb3..60c61db2 100644 --- a/packages/shorebird_cli/test/src/artifact_builder/artifact_builder_test.dart +++ b/packages/shorebird_cli/test/src/artifact_builder/artifact_builder_test.dart @@ -347,12 +347,78 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod verify( () => shorebirdProcess.run( 'flutter', - ['build', 'appbundle', '-h'], + ['build', 'appbundle', '-h', '-v'], runInShell: false, ), ).called(1); }); + test( + 'detects --shorebird-trace even though it is hidden from -h', + () async { + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 7)); + // Flutter registers --shorebird-trace with `hide: !verboseHelp`, so it + // only appears in verbose help. Model that: plain `-h` omits the flag, + // `-h -v` includes it. Probing without `-v` (the original bug) would + // miss it and silently disable tracing on every supported build. + when( + () => shorebirdProcess.run( + 'flutter', + ['build', 'appbundle', '-h'], + runInShell: false, + ), + ).thenAnswer( + (_) async => ShorebirdProcessResult( + exitCode: ExitCode.success.code, + stdout: '--release', + stderr: '', + ), + ); + when( + () => shorebirdProcess.run( + 'flutter', + ['build', 'appbundle', '-h', '-v'], + runInShell: false, + ), + ).thenAnswer( + (_) async => ShorebirdProcessResult( + exitCode: ExitCode.success.code, + stdout: '--release\n--shorebird-trace', + stderr: '', + ), + ); + + await runWithOverrides(() async { + await builder.prepareBuildTrace(platform: 'android'); + await builder.buildAppBundle(); + }); + + final expectedTracePath = p.join( + projectRoot.path, + 'build', + 'shorebird', + 'debug', + 'build-trace-android.json', + ); + verify( + () => shorebirdProcess.stream( + 'flutter', + [ + 'build', + 'appbundle', + '--release', + '--shorebird-trace=$expectedTracePath', + ], + environment: any(named: 'environment'), + runInShell: false, + onStart: any(named: 'onStart'), + ), + ).called(1); + }, + ); + test('skips trace when help probe throws', () async { when( () => shorebirdFlutter.resolveFlutterVersion(any()), @@ -360,7 +426,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod when( () => shorebirdProcess.run( 'flutter', - ['build', 'appbundle', '-h'], + ['build', 'appbundle', '-h', '-v'], runInShell: false, ), ).thenThrow(Exception('process failed')); @@ -921,7 +987,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod when( () => shorebirdProcess.run( 'flutter', - ['build', 'aar', '-h'], + ['build', 'aar', '-h', '-v'], runInShell: false, ), ).thenAnswer( @@ -1123,7 +1189,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod when( () => shorebirdProcess.run( 'flutter', - ['build', 'linux', '-h'], + ['build', 'linux', '-h', '-v'], runInShell: false, ), ).thenAnswer( @@ -1300,7 +1366,7 @@ Reason: Exited with code 70.'''), when( () => shorebirdProcess.run( 'flutter', - ['build', 'macos', '-h'], + ['build', 'macos', '-h', '-v'], runInShell: false, ), ).thenAnswer( @@ -1789,7 +1855,7 @@ Reason: Exited with code 70.'''), when( () => shorebirdProcess.run( 'flutter', - ['build', 'ios-framework', '-h'], + ['build', 'ios-framework', '-h', '-v'], runInShell: false, ), ).thenAnswer( @@ -2181,7 +2247,7 @@ Reason: Exited with code 70.'''), when( () => shorebirdProcess.run( 'flutter', - ['build', 'windows', '-h'], + ['build', 'windows', '-h', '-v'], runInShell: false, ), ).thenAnswer( diff --git a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart index 38fd9f0e..07daf1de 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart @@ -423,6 +423,9 @@ $body when( () => artifactManager.getXcarchiveDirectory(), ).thenReturn(xcarchiveDirectory); + when( + () => artifactManager.getIpa(), + ).thenReturn(File(p.join(Directory.systemTemp.path, 'app.ipa'))); when( () => codeSigner.base64PublicKeyFromPem(any()), @@ -660,6 +663,42 @@ $body }); }); + group('when codesigning and ipa not found after build', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(true); + when(() => artifactManager.getIpa()).thenReturn(null); + }); + + test('logs message and exits with code 70', () async { + await expectLater( + () => runWithOverrides(iosReleaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any(that: contains('Unable to find generated IPA')), + ), + ).called(1); + }); + }); + + group('when not codesigning and ipa not found after build', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(false); + when(() => artifactManager.getIpa()).thenReturn(null); + }); + + test('does not check for the ipa and returns xcarchive path', () async { + expect( + await runWithOverrides(iosReleaser.buildReleaseArtifacts), + equals(xcarchiveDirectory), + ); + + verifyNever(() => artifactManager.getIpa()); + }); + }); + group('when --obfuscate is passed', () { setUp(() { when(() => argResults['obfuscate']).thenReturn(true); diff --git a/packages/shorebird_code_push_client/lib/src/code_push_client.dart b/packages/shorebird_code_push_client/lib/src/code_push_client.dart index 9bac5a9f..5528c142 100644 --- a/packages/shorebird_code_push_client/lib/src/code_push_client.dart +++ b/packages/shorebird_code_push_client/lib/src/code_push_client.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:shorebird_code_push_client/src/version.dart'; @@ -91,23 +92,39 @@ class CodePushClient { http.Client? httpClient, Uri? hostedUri, Map? customHeaders, + @visibleForTesting + Duration uploadRetryBaseDelay = const Duration(seconds: 1), }) : _httpClient = _CodePushHttpClient(httpClient ?? http.Client(), { ...standardHeaders, ...?customHeaders, }), + _uploadRetryBaseDelay = uploadRetryBaseDelay, hostedUri = hostedUri ?? defaultHostedUri; /// Default URL for the open self-hosted Shorebird server. static final Uri defaultHostedUri = Uri.parse('http://localhost:8080'); /// The standard headers applied to all requests. + @visibleForTesting static const standardHeaders = {'x-version': packageVersion}; /// The default error message to use when an unknown error occurs. + @visibleForTesting static const unknownErrorMessage = 'An unknown error occurred.'; + /// The status GCS returns ("Resume Incomplete") between resumable chunks. + static const _resumeIncompleteStatus = 308; + + /// The maximum number of consecutive failures tolerated while uploading a + /// single resumable session before the upload is abandoned. + static const _maxUploadFailures = 5; + final http.Client _httpClient; + /// The base delay for exponential backoff between resumable upload retries. + /// Doubles with each consecutive failure. + final Duration _uploadRetryBaseDelay; + /// The hosted uri for the Shorebird CodePush API. final Uri hostedUri; @@ -163,17 +180,12 @@ class CodePushClient { json.decode(body) as Map, ); - final uploadRequest = http.MultipartRequest('POST', Uri.parse(decoded.url)) - ..files.add(file); - - final uploadResponse = await _httpClient.send(uploadRequest); - - if (!uploadResponse.isSuccess) { - throw CodePushException( - message: - '''Failed to upload artifact (${uploadResponse.reasonPhrase} '${uploadResponse.statusCode})''', - ); - } + await _uploadArtifact( + artifactPath: artifactPath, + multipartFile: file, + url: decoded.url, + uploadMethod: decoded.uploadMethod, + ); } /// Create a new artifact for a specific [releaseId]. @@ -222,17 +234,152 @@ class CodePushClient { json.decode(body) as Map, ); - final uploadRequest = http.MultipartRequest('POST', Uri.parse(decoded.url)) - ..files.add(file); + await _uploadArtifact( + artifactPath: artifactPath, + multipartFile: file, + url: decoded.url, + uploadMethod: decoded.uploadMethod, + ); + } - final uploadResponse = await _httpClient.send(uploadRequest); - - if (!uploadResponse.isSuccess) { - throw CodePushException( - message: - '''Failed to upload artifact (${uploadResponse.reasonPhrase} '${uploadResponse.statusCode})''', + /// Uploads an artifact's bytes to storage using the method the server + /// selected in the create response. + Future _uploadArtifact({ + required String artifactPath, + required http.MultipartFile multipartFile, + required String url, + required ArtifactUploadMethod? uploadMethod, + }) async { + if (uploadMethod == ArtifactUploadMethod.resumable) { + await _resumableUpload( + sessionUri: Uri.parse(url), + artifactPath: artifactPath, ); + return; } + + // Legacy single multipart POST. Remove once the server no longer returns + // ArtifactUploadMethod.multipart (i.e. all supported clients are new + // enough to receive a resumable session). + final uploadRequest = http.MultipartRequest('POST', Uri.parse(url)) + ..files.add(multipartFile); + final uploadResponse = await _httpClient.send(uploadRequest); + if (!uploadResponse.isSuccess) { + throw _uploadFailed(uploadResponse); + } + } + + /// Uploads [artifactPath] to a GCS resumable session at [sessionUri] by + /// PUTing the bytes in fixed-size chunks with a `Content-Range` header, + /// resuming from the last byte GCS acknowledged if a chunk fails. The + /// session was initiated (and size-bound) server-side. + Future _resumableUpload({ + required Uri sessionUri, + required String artifactPath, + }) async { + // GCS requires chunk sizes to be a multiple of 256 KiB (except the last). + const chunkSize = 8 * 1024 * 1024; + + final file = File(artifactPath); + final total = await file.length(); + final raf = await file.open(); + var failures = 0; + try { + var offset = 0; + while (offset < total) { + final end = offset + chunkSize < total ? offset + chunkSize : total; + await raf.setPosition(offset); + final chunk = await raf.read(end - offset); + + final http.StreamedResponse response; + try { + response = await _httpClient.send( + http.Request('PUT', sessionUri) + ..bodyBytes = chunk + ..headers['content-range'] = 'bytes $offset-${end - 1}/$total', + ); + } on Exception { + // Network failure mid-chunk: back off, ask GCS how far it got, and + // resume from there. + if (++failures > _maxUploadFailures) rethrow; + await _backoff(failures); + offset = await _queryResumeOffset(sessionUri, total); + continue; + } + + final status = response.statusCode; + await response.stream.drain(); + + if (status == HttpStatus.ok || status == HttpStatus.created) return; + + if (status == _resumeIncompleteStatus) { + // A 308 reports GCS's stored byte count in the `range` header. Its + // absence means GCS has no bytes yet, so we must restart from 0 + // (per the resumable upload status-check docs). Treat a lack of + // forward progress as a failure so a stuck session can't spin + // forever. + final next = _parseRangeEnd(response.headers['range']) ?? 0; + if (next > offset) { + failures = 0; + } else if (++failures > _maxUploadFailures) { + throw _uploadFailed(response); + } else { + await _backoff(failures); + } + offset = next; + } else if (status >= HttpStatus.internalServerError) { + // Transient server error (5xx): recover the same way as a mid-chunk + // network failure — back off, then resume from where GCS left off + // rather than re-sending bytes it has already persisted. + if (++failures > _maxUploadFailures) throw _uploadFailed(response); + await _backoff(failures); + offset = await _queryResumeOffset(sessionUri, total); + } else { + throw _uploadFailed(response); + } + } + } finally { + await raf.close(); + } + } + + /// Waits with exponential backoff before retrying a resumable upload, + /// doubling [_uploadRetryBaseDelay] with each consecutive [failures]. + Future _backoff(int failures) => + Future.delayed(_uploadRetryBaseDelay * (1 << (failures - 1))); + + /// Queries a resumable [sessionUri] for the number of bytes GCS has received + /// so far, returning the offset to resume from. + Future _queryResumeOffset(Uri sessionUri, int total) async { + final response = await _httpClient.send( + http.Request('PUT', sessionUri) + ..headers['content-range'] = 'bytes */$total', + ); + final status = response.statusCode; + await response.stream.drain(); + if (status == HttpStatus.ok || status == HttpStatus.created) return total; + if (status == _resumeIncompleteStatus) { + return _parseRangeEnd(response.headers['range']) ?? 0; + } + throw _uploadFailed(response); + } + + /// Parses the next byte offset from a GCS `Range: bytes=0-X` header, + /// returning `X + 1`, or null if the header is absent/malformed. + int? _parseRangeEnd(String? range) { + if (range == null) return null; + final dash = range.lastIndexOf('-'); + if (dash == -1) return null; + final last = int.tryParse(range.substring(dash + 1)); + return last == null ? null : last + 1; + } + + /// The exception thrown when an artifact upload request fails. + CodePushException _uploadFailed(http.BaseResponse response) { + final reason = response.reasonPhrase; + return CodePushException( + message: 'Failed to upload artifact ($reason ${response.statusCode})', + ); } /// Create a new app with the provided [displayName]. diff --git a/packages/shorebird_code_push_client/pubspec.yaml b/packages/shorebird_code_push_client/pubspec.yaml index 7087b6b3..a02b4639 100644 --- a/packages/shorebird_code_push_client/pubspec.yaml +++ b/packages/shorebird_code_push_client/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: collection: ^1.18.0 http: ^1.5.0 + meta: ^1.18.3 path: ^1.9.0 shorebird_code_push_protocol: path: ../shorebird_code_push_protocol @@ -21,4 +22,4 @@ dev_dependencies: build_verify: ^3.1.1 build_version: ^2.0.0 mocktail: ^1.0.5 - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart index 64c3f6a3..c4f79f7d 100644 --- a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart +++ b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart @@ -47,6 +47,8 @@ void main() { codePushClient = CodePushClient( httpClient: httpClient, customHeaders: customHeaders, + // Disable backoff delays so retry paths run instantly under test. + uploadRetryBaseDelay: Duration.zero, ); when(() => httpClient.send(any())).thenAnswer( (_) async => http.StreamedResponse(const Stream.empty(), HttpStatus.ok), @@ -460,6 +462,62 @@ void main() { ), ); }); + + test('uploads via a resumable session when the server ' + 'selects resumable', () async { + const artifactId = 42; + const sessionUrl = 'https://storage.googleapis.com/session?upload_id=a'; + final responses = [ + http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + const CreatePatchArtifactResponse( + id: artifactId, + patchId: patchId, + arch: arch, + platform: platform, + hash: hash, + size: size, + url: sessionUrl, + uploadMethod: ArtifactUploadMethod.resumable, + ), + ), + ), + ), + HttpStatus.ok, + ), + http.StreamedResponse(const Stream.empty(), HttpStatus.ok), + ]; + when( + () => httpClient.send(any()), + ).thenAnswer((_) async => responses.removeAt(0)); + + final tempDir = Directory.systemTemp.createTempSync(); + final fixture = File(path.join(tempDir.path, 'patch.txt')) + ..writeAsBytesSync([1, 2, 3, 4, 5]); + + await expectLater( + codePushClient.createPatchArtifact( + appId: appId, + artifactPath: fixture.path, + patchId: patchId, + arch: arch, + platform: platform, + hash: hash, + ), + completes, + ); + + final requests = verify( + () => httpClient.send(captureAny()), + ).captured.cast(); + expect(requests, hasLength(2)); + final uploadRequest = requests.last; + expect(uploadRequest.method, equals('PUT')); + expect(uploadRequest.url, equals(Uri.parse(sessionUrl))); + expect(uploadRequest.headers['content-range'], equals('bytes 0-4/5')); + }); }); group('createReleaseArtifact', () { @@ -807,6 +865,314 @@ void main() { ), ); }); + + test('uploads via a resumable session when the server ' + 'selects resumable', () async { + const artifactId = 42; + const sessionUrl = 'https://storage.googleapis.com/session?upload_id=a'; + final responses = [ + http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + const CreateReleaseArtifactResponse( + id: artifactId, + releaseId: releaseId, + arch: arch, + platform: platform, + hash: hash, + size: size, + url: sessionUrl, + uploadMethod: ArtifactUploadMethod.resumable, + ), + ), + ), + ), + HttpStatus.ok, + ), + http.StreamedResponse(const Stream.empty(), HttpStatus.ok), + ]; + when( + () => httpClient.send(any()), + ).thenAnswer((_) async => responses.removeAt(0)); + + final tempDir = Directory.systemTemp.createTempSync(); + final fixture = File(path.join(tempDir.path, 'release.txt')) + ..writeAsBytesSync([1, 2, 3, 4, 5]); + + await expectLater( + codePushClient.createReleaseArtifact( + appId: appId, + artifactPath: fixture.path, + releaseId: releaseId, + arch: arch, + platform: platform, + hash: hash, + canSideload: canSideload, + podfileLockHash: podfileLockHash, + ), + completes, + ); + + final requests = verify( + () => httpClient.send(captureAny()), + ).captured.cast(); + expect(requests, hasLength(2)); + final uploadRequest = requests.last; + expect(uploadRequest.method, equals('PUT')); + expect(uploadRequest.url, equals(Uri.parse(sessionUrl))); + expect(uploadRequest.headers['content-range'], equals('bytes 0-4/5')); + }); + + group('resumable upload driver', () { + const sessionUrl = 'https://storage.googleapis.com/session?id=abc'; + + http.StreamedResponse resumableMeta() => http.StreamedResponse( + Stream.value( + utf8.encode( + json.encode( + const CreateReleaseArtifactResponse( + id: 42, + releaseId: releaseId, + arch: arch, + platform: platform, + hash: hash, + size: size, + url: sessionUrl, + uploadMethod: ArtifactUploadMethod.resumable, + ), + ), + ), + ), + HttpStatus.ok, + ); + + String fiveByteFixture() { + final dir = Directory.systemTemp.createTempSync(); + return (File( + path.join(dir.path, 'release.txt'), + )..writeAsBytesSync([1, 2, 3, 4, 5])).path; + } + + void stubActions( + List Function()> actions, + ) { + when( + () => httpClient.send(any()), + ).thenAnswer((_) => actions.removeAt(0)()); + } + + Future upload(String artifactPath) => + codePushClient.createReleaseArtifact( + appId: appId, + artifactPath: artifactPath, + releaseId: releaseId, + arch: arch, + platform: platform, + hash: hash, + canSideload: canSideload, + podfileLockHash: null, + ); + + List capturedPuts() => + verify( + () => httpClient.send(captureAny()), + ).captured + .cast() + .where((r) => r.method == 'PUT') + .toList(); + + test('continues to the next chunk on a 308 response', () async { + stubActions([ + () async => resumableMeta(), + () async => http.StreamedResponse( + const Stream.empty(), + 308, + headers: const {'range': 'bytes=0-2'}, + ), + () async => + http.StreamedResponse(const Stream.empty(), HttpStatus.ok), + ]); + + await expectLater(upload(fiveByteFixture()), completes); + + final puts = capturedPuts(); + expect(puts, hasLength(2)); + expect(puts[0].headers['content-range'], 'bytes 0-4/5'); + expect(puts[1].headers['content-range'], 'bytes 3-4/5'); + }); + + test('throws when a chunk returns an error status', () async { + stubActions([ + () async => resumableMeta(), + () async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.badRequest, + ), + ]); + + await expectLater( + upload(fiveByteFixture()), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Failed to upload artifact'), + ), + ), + ); + }); + + test( + 'queries the offset and resumes after a network failure', + () async { + stubActions([ + () async => resumableMeta(), + () async => throw http.ClientException('connection reset'), + () async => http.StreamedResponse( + const Stream.empty(), + 308, + headers: const {'range': 'bytes=0-1'}, + ), + () async => + http.StreamedResponse(const Stream.empty(), HttpStatus.ok), + ]); + + await expectLater(upload(fiveByteFixture()), completes); + + final puts = capturedPuts(); + // failed chunk, status query, resumed chunk from offset 2. + expect(puts, hasLength(3)); + expect(puts[1].headers['content-range'], 'bytes */5'); + expect(puts[2].headers['content-range'], 'bytes 2-4/5'); + }, + ); + + test('throws when the status query returns an error', () async { + stubActions([ + () async => resumableMeta(), + () async => throw http.ClientException('connection reset'), + () async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.badRequest, + ), + ]); + + await expectLater( + upload(fiveByteFixture()), + throwsA(isA()), + ); + }); + + test('restarts from the beginning on a 308 with no range', () async { + stubActions([ + () async => resumableMeta(), + // 308 without a `range` header: GCS stored nothing, restart at 0. + () async => http.StreamedResponse(const Stream.empty(), 308), + () async => + http.StreamedResponse(const Stream.empty(), HttpStatus.ok), + ]); + + await expectLater(upload(fiveByteFixture()), completes); + + final puts = capturedPuts(); + expect(puts, hasLength(2)); + expect(puts[0].headers['content-range'], 'bytes 0-4/5'); + // Restarts from offset 0 rather than advancing. + expect(puts[1].headers['content-range'], 'bytes 0-4/5'); + }); + + test('throws when a 308 never makes forward progress', () async { + // A session stuck at offset 0 keeps returning a 308 with no range; + // the upload must give up rather than spin forever. + stubActions([ + () async => resumableMeta(), + for (var i = 0; i < 6; i++) + () async => http.StreamedResponse(const Stream.empty(), 308), + ]); + + await expectLater( + upload(fiveByteFixture()), + throwsA(isA()), + ); + }); + + test('queries the offset and resumes after a 5xx', () async { + stubActions([ + () async => resumableMeta(), + () async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.serviceUnavailable, + ), + () async => http.StreamedResponse( + const Stream.empty(), + 308, + headers: const {'range': 'bytes=0-1'}, + ), + () async => + http.StreamedResponse(const Stream.empty(), HttpStatus.ok), + ]); + + await expectLater(upload(fiveByteFixture()), completes); + + final puts = capturedPuts(); + // failed chunk, status query, resumed chunk from offset 2. + expect(puts, hasLength(3)); + expect(puts[0].headers['content-range'], 'bytes 0-4/5'); + expect(puts[1].headers['content-range'], 'bytes */5'); + expect(puts[2].headers['content-range'], 'bytes 2-4/5'); + }); + + test('throws when a 5xx persists through the status query', () async { + stubActions([ + () async => resumableMeta(), + () async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.serviceUnavailable, + ), + // The status query also fails, so we surface the error. + () async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.serviceUnavailable, + ), + ]); + + await expectLater( + upload(fiveByteFixture()), + throwsA(isA()), + ); + }); + + test('throws after exhausting retries on repeated 5xx', () async { + // Each chunk PUT fails with a 5xx while the status query reports no + // forward progress, so failures accumulate until we give up. + final actions = Function()>[ + () async => resumableMeta(), + ]; + for (var i = 0; i < 6; i++) { + actions + ..add( + () async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.serviceUnavailable, + ), + ) + ..add( + () async => http.StreamedResponse( + const Stream.empty(), + 308, + headers: const {'range': 'bytes=0-1'}, + ), + ); + } + stubActions(actions); + + await expectLater( + upload(fiveByteFixture()), + throwsA(isA()), + ); + }); + }); }); group('createApp', () { diff --git a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart index 6f970a76..ed951873 100644 --- a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart +++ b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart @@ -24,13 +24,16 @@ export 'package:shorebird_code_push_protocol/src/messages/create_release_artifac export 'package:shorebird_code_push_protocol/src/messages/create_user/create_user_request.dart'; export 'package:shorebird_code_push_protocol/src/messages/error_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_active_hours/get_active_hours_response.dart'; +export 'package:shorebird_code_push_protocol/src/messages/get_activity_heatmap/get_activity_heatmap_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_apps/get_apps_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_gcp_download_speed_test_url/get_gcp_download_speed_test_url200_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_gcp_upload_speed_test_url/get_gcp_upload_speed_test_url200_response.dart'; +export 'package:shorebird_code_push_protocol/src/messages/get_new_devices/get_new_devices_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_organization_apps/get_organization_apps_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_organization_users/get_organization_users_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_organizations_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_patch_adoption/get_patch_adoption_response.dart'; +export 'package:shorebird_code_push_protocol/src/messages/get_patch_metric_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_release/get_release_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_release_artifacts/get_release_artifacts_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_release_patches/get_release_patches_response.dart'; @@ -44,11 +47,21 @@ export 'package:shorebird_code_push_protocol/src/messages/update_app_collaborato export 'package:shorebird_code_push_protocol/src/messages/update_patch/update_patch_request.dart'; export 'package:shorebird_code_push_protocol/src/messages/update_release/update_release_request.dart'; export 'package:shorebird_code_push_protocol/src/models/active_hour_entry.dart'; +export 'package:shorebird_code_push_protocol/src/models/activity_heatmap_cell.dart'; export 'package:shorebird_code_push_protocol/src/models/app.dart'; export 'package:shorebird_code_push_protocol/src/models/app_collaborator_role.dart'; export 'package:shorebird_code_push_protocol/src/models/app_metadata.dart'; +export 'package:shorebird_code_push_protocol/src/models/artifact_upload_method.dart'; export 'package:shorebird_code_push_protocol/src/models/channel.dart'; +export 'package:shorebird_code_push_protocol/src/models/get_app_patch_downloads_parameter2.dart'; +export 'package:shorebird_code_push_protocol/src/models/get_app_patch_downloads_parameter3.dart'; +export 'package:shorebird_code_push_protocol/src/models/get_app_patch_installs_parameter2.dart'; +export 'package:shorebird_code_push_protocol/src/models/get_app_patch_installs_parameter3.dart'; export 'package:shorebird_code_push_protocol/src/models/get_patch_adoption_parameter3.dart'; +export 'package:shorebird_code_push_protocol/src/models/get_release_patch_downloads_parameter2.dart'; +export 'package:shorebird_code_push_protocol/src/models/get_release_patch_downloads_parameter3.dart'; +export 'package:shorebird_code_push_protocol/src/models/get_release_patch_installs_parameter2.dart'; +export 'package:shorebird_code_push_protocol/src/models/get_release_patch_installs_parameter3.dart'; export 'package:shorebird_code_push_protocol/src/models/get_unique_users_parameter2.dart'; export 'package:shorebird_code_push_protocol/src/models/get_unique_users_parameter3.dart'; export 'package:shorebird_code_push_protocol/src/models/latest_release.dart'; @@ -62,6 +75,10 @@ export 'package:shorebird_code_push_protocol/src/models/patch_adoption_entry.dar export 'package:shorebird_code_push_protocol/src/models/patch_adoption_point.dart'; export 'package:shorebird_code_push_protocol/src/models/patch_artifact.dart'; export 'package:shorebird_code_push_protocol/src/models/patch_check_metadata.dart'; +export 'package:shorebird_code_push_protocol/src/models/patch_metric_breakdown_entry.dart'; +export 'package:shorebird_code_push_protocol/src/models/patch_metric_current_window.dart'; +export 'package:shorebird_code_push_protocol/src/models/patch_metric_time_series_entry.dart'; +export 'package:shorebird_code_push_protocol/src/models/patch_metric_window.dart'; export 'package:shorebird_code_push_protocol/src/models/pending_release.dart'; export 'package:shorebird_code_push_protocol/src/models/private_user.dart'; export 'package:shorebird_code_push_protocol/src/models/public_user.dart'; diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/create_patch_artifact/create_patch_artifact_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/create_patch_artifact/create_patch_artifact_response.dart index ba8e102c..3da75b25 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/create_patch_artifact/create_patch_artifact_response.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/create_patch_artifact/create_patch_artifact_response.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/artifact_upload_method.dart'; import 'package:shorebird_code_push_protocol/src/models/release_platform.dart'; /// {@template create_patch_artifact_response} @@ -16,6 +17,7 @@ class CreatePatchArtifactResponse { required this.hash, required this.size, required this.url, + this.uploadMethod, }); /// Converts a `Map` to a [CreatePatchArtifactResponse]. @@ -31,6 +33,9 @@ class CreatePatchArtifactResponse { hash: json['hash'] as String, size: json['size'] as int, url: json['url'] as String, + uploadMethod: ArtifactUploadMethod.maybeFromJson( + json['upload_method'] as String?, + ), ), ); } @@ -64,9 +69,16 @@ class CreatePatchArtifactResponse { /// The size of the artifact in bytes. final int size; - /// The upload URL for the artifact. + /// The upload URL for the artifact (a signed URL for [ArtifactUploadMethod + /// .multipart], or a resumable session URI for [ArtifactUploadMethod + /// .resumable]). final String url; + /// How the client should upload the artifact bytes to [url]. Null on + /// responses from older servers, which always implied + /// [ArtifactUploadMethod.multipart]. + final ArtifactUploadMethod? uploadMethod; + /// Converts a [CreatePatchArtifactResponse] to a `Map`. Map toJson() { return { @@ -77,6 +89,7 @@ class CreatePatchArtifactResponse { 'hash': hash, 'size': size, 'url': url, + 'upload_method': uploadMethod?.toJson(), }; } @@ -89,6 +102,7 @@ class CreatePatchArtifactResponse { hash, size, url, + uploadMethod, ]); @override @@ -101,6 +115,7 @@ class CreatePatchArtifactResponse { platform == other.platform && hash == other.hash && size == other.size && - url == other.url; + url == other.url && + uploadMethod == other.uploadMethod; } } diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/create_release_artifact/create_release_artifact_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/create_release_artifact/create_release_artifact_response.dart index bd37eee5..99a3e18e 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/create_release_artifact/create_release_artifact_response.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/create_release_artifact/create_release_artifact_response.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/artifact_upload_method.dart'; import 'package:shorebird_code_push_protocol/src/models/release_platform.dart'; /// {@template create_release_artifact_response} @@ -16,6 +17,7 @@ class CreateReleaseArtifactResponse { required this.hash, required this.size, required this.url, + this.uploadMethod, }); /// Converts a `Map` to a [CreateReleaseArtifactResponse]. @@ -31,6 +33,9 @@ class CreateReleaseArtifactResponse { hash: json['hash'] as String, size: json['size'] as int, url: json['url'] as String, + uploadMethod: ArtifactUploadMethod.maybeFromJson( + json['upload_method'] as String?, + ), ), ); } @@ -64,9 +69,16 @@ class CreateReleaseArtifactResponse { /// The size of the artifact in bytes. final int size; - /// The upload URL for the artifact. + /// The upload URL for the artifact (a signed URL for [ArtifactUploadMethod + /// .multipart], or a resumable session URI for [ArtifactUploadMethod + /// .resumable]). final String url; + /// How the client should upload the artifact bytes to [url]. Null on + /// responses from older servers, which always implied + /// [ArtifactUploadMethod.multipart]. + final ArtifactUploadMethod? uploadMethod; + /// Converts a [CreateReleaseArtifactResponse] to a `Map`. Map toJson() { return { @@ -77,6 +89,7 @@ class CreateReleaseArtifactResponse { 'hash': hash, 'size': size, 'url': url, + 'upload_method': uploadMethod?.toJson(), }; } @@ -89,6 +102,7 @@ class CreateReleaseArtifactResponse { hash, size, url, + uploadMethod, ]); @override @@ -101,6 +115,7 @@ class CreateReleaseArtifactResponse { platform == other.platform && hash == other.hash && size == other.size && - url == other.url; + url == other.url && + uploadMethod == other.uploadMethod; } } diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/get_activity_heatmap/get_activity_heatmap_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/get_activity_heatmap/get_activity_heatmap_response.dart new file mode 100644 index 00000000..cf4a2dd3 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/messages/get_activity_heatmap/get_activity_heatmap_response.dart @@ -0,0 +1,100 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/activity_heatmap_cell.dart'; + +/// {@template get_activity_heatmap_response} +/// The response body for GET /apps/{appId}/metrics/activity-heatmap. A 7×24 +/// grid of average active devices per UTC weekday-hour, powering the +/// insights activity heatmap. +/// {@endtemplate} +@immutable +class GetActivityHeatmapResponse { + /// {@macro get_activity_heatmap_response} + const GetActivityHeatmapResponse({ + required this.cells, + required this.busiestDayOfWeekUtc, + required this.busiestHourUtc, + required this.lookbackDays, + required this.asOf, + }); + + /// Converts a `Map` to a [GetActivityHeatmapResponse]. + factory GetActivityHeatmapResponse.fromJson(Map json) { + return parseFromJson( + 'GetActivityHeatmapResponse', + json, + () => GetActivityHeatmapResponse( + cells: (json['cells'] as List) + .map( + (e) => ActivityHeatmapCell.fromJson(e as Map), + ) + .toList(), + busiestDayOfWeekUtc: + checkedKey(json, 'busiest_day_of_week_utc') as int?, + busiestHourUtc: checkedKey(json, 'busiest_hour_utc') as int?, + lookbackDays: json['lookback_days'] as int, + asOf: DateTime.parse(json['as_of'] as String), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static GetActivityHeatmapResponse? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return GetActivityHeatmapResponse.fromJson(json); + } + + /// 168 entries (7 weekdays × 24 hours), zero-filled, ordered by + /// day_of_week_utc (1–7) then hour_utc (0–23) ascending. + final List cells; + + /// UTC day-of-week (1–7) of the cell with the highest average active + /// devices, for peak-relative coloring and labeling. Null when there is + /// no data. + final int? busiestDayOfWeekUtc; + + /// UTC hour (0–23) of the busiest cell. Null when there is no data. + final int? busiestHourUtc; + + /// Number of days of history the heatmap is computed over. + final int lookbackDays; + + /// Server's UTC timestamp at the moment the response was constructed. + /// Not a freshness indicator for the underlying data, which is + /// refreshed by an hourly scheduled query and may lag by up to ~1 hour. + final DateTime asOf; + + /// Converts a [GetActivityHeatmapResponse] to a `Map`. + Map toJson() { + return { + 'cells': cells.map((e) => e.toJson()).toList(), + 'busiest_day_of_week_utc': busiestDayOfWeekUtc, + 'busiest_hour_utc': busiestHourUtc, + 'lookback_days': lookbackDays, + 'as_of': asOf.toIso8601String(), + }; + } + + @override + int get hashCode => Object.hashAll([ + listHash(cells), + busiestDayOfWeekUtc, + busiestHourUtc, + lookbackDays, + asOf, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is GetActivityHeatmapResponse && + listsEqual(cells, other.cells) && + busiestDayOfWeekUtc == other.busiestDayOfWeekUtc && + busiestHourUtc == other.busiestHourUtc && + lookbackDays == other.lookbackDays && + asOf == other.asOf; + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/get_new_devices/get_new_devices_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/get_new_devices/get_new_devices_response.dart new file mode 100644 index 00000000..057d13a6 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/messages/get_new_devices/get_new_devices_response.dart @@ -0,0 +1,88 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; + +/// {@template get_new_devices_response} +/// The response body for GET /apps/{appId}/metrics/new-devices. +/// {@endtemplate} +@immutable +class GetNewDevicesResponse { + /// {@macro get_new_devices_response} + const GetNewDevicesResponse({ + required this.current, + required this.previous, + required this.windowDays, + required this.asOf, + }); + + /// Converts a `Map` to a [GetNewDevicesResponse]. + factory GetNewDevicesResponse.fromJson(Map json) { + return parseFromJson( + 'GetNewDevicesResponse', + json, + () => GetNewDevicesResponse( + current: json['current'] as int, + previous: checkedKey(json, 'previous') as int?, + windowDays: json['window_days'] as int, + asOf: DateTime.parse(json['as_of'] as String), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static GetNewDevicesResponse? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return GetNewDevicesResponse.fromJson(json); + } + + /// Devices first seen in the window `[as_of − window_days, as_of)`. + /// An exact (non-HLL) count. + final int current; + + /// Devices first seen in the equal-length window immediately + /// preceding the current one, or null when that window would begin + /// before the metrics data floor (comparing against + /// partially-recorded history would show a misleading delta). + final int? previous; + + /// The window length in days. Hardcoded server-side in v1; echoed + /// so clients label the metric from the response rather than + /// assuming a length. + final int windowDays; + + /// Server's UTC timestamp at the moment the response was + /// constructed. Not a freshness indicator for the underlying + /// data, which is refreshed by an hourly scheduled query and + /// may lag by up to ~1 hour. + final DateTime asOf; + + /// Converts a [GetNewDevicesResponse] to a `Map`. + Map toJson() { + return { + 'current': current, + 'previous': previous, + 'window_days': windowDays, + 'as_of': asOf.toIso8601String(), + }; + } + + @override + int get hashCode => Object.hashAll([ + current, + previous, + windowDays, + asOf, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is GetNewDevicesResponse && + current == other.current && + previous == other.previous && + windowDays == other.windowDays && + asOf == other.asOf; + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/get_patch_metric_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/get_patch_metric_response.dart new file mode 100644 index 00000000..606b2614 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/messages/get_patch_metric_response.dart @@ -0,0 +1,104 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/patch_metric_current_window.dart'; +import 'package:shorebird_code_push_protocol/src/models/patch_metric_window.dart'; + +/// {@template get_patch_metric_response} +/// The response body for the patch-installs and patch-downloads metric +/// endpoints (app- and release-scoped): a current/previous envelope. +/// `previous` covers the equal-length window immediately preceding +/// `current`; period-over-period deltas are client display logic over the +/// two totals. `previous` is omitted only when the prior window predates +/// the data floor (no comparison data exists). When it reaches past the +/// plan's metrics-history horizon, `previous` is still present with its +/// total — the delta renders — but without a `time_series` (no +/// prior-window overlay): granular history is the resolution the horizon +/// gates, the scalar comparison is not. +/// {@endtemplate} +@immutable +class GetPatchMetricResponse { + /// {@macro get_patch_metric_response} + const GetPatchMetricResponse({ + required this.asOf, + required this.granularity, + required this.current, + this.previous, + }); + + /// Converts a `Map` to a [GetPatchMetricResponse]. + factory GetPatchMetricResponse.fromJson(Map json) { + return parseFromJson( + 'GetPatchMetricResponse', + json, + () => GetPatchMetricResponse( + asOf: DateTime.parse(json['as_of'] as String), + granularity: checkedKey(json, 'granularity') as String?, + current: PatchMetricCurrentWindow.fromJson( + json['current'] as Map, + ), + previous: PatchMetricWindow.maybeFromJson( + json['previous'] as Map?, + ), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static GetPatchMetricResponse? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return GetPatchMetricResponse.fromJson(json); + } + + /// Server's UTC timestamp at the moment the response was constructed. + /// Not a freshness indicator for the underlying data, which is + /// refreshed by an hourly scheduled job and may lag by up to ~1 hour. + final DateTime asOf; + + /// The time-series bucket resolution (`hour`, `day`, or `week`), or + /// null when no time series was requested. Applies to both windows. + final String? granularity; + + /// The `current` window of the patch-metric envelope: the base window atom + /// plus the optional `breakdown`. Only `current` carries a breakdown — no + /// chart renders a previous-window breakdown, so the asymmetry is declared + /// in the contract rather than left as an optional-but-never-populated + /// field. + final PatchMetricCurrentWindow current; + + /// One window of the patch-metric envelope: the summed count over the + /// window's effective range, with a per-bucket series when a `granularity` + /// was requested. This base atom is the full shape of `previous`; + /// `current` extends it (see PatchMetricCurrentWindow). + final PatchMetricWindow? previous; + + /// Converts a [GetPatchMetricResponse] to a `Map`. + Map toJson() { + return { + 'as_of': asOf.toIso8601String(), + 'granularity': granularity, + 'current': current.toJson(), + 'previous': previous?.toJson(), + }; + } + + @override + int get hashCode => Object.hashAll([ + asOf, + granularity, + current, + previous, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is GetPatchMetricResponse && + asOf == other.asOf && + granularity == other.granularity && + current == other.current && + previous == other.previous; + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/patch_check/patch_check_request.dart b/packages/shorebird_code_push_protocol/lib/src/messages/patch_check/patch_check_request.dart index 7ed22f28..0cf214ea 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/patch_check/patch_check_request.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/patch_check/patch_check_request.dart @@ -15,7 +15,6 @@ class PatchCheckRequest { required this.appId, required this.channel, this.patchNumber, - this.patchHash, this.clientId, this.currentPatchNumber, }); @@ -28,7 +27,6 @@ class PatchCheckRequest { () => PatchCheckRequest( releaseVersion: json['release_version'] as String, patchNumber: json['patch_number'] as int?, - patchHash: json['patch_hash'] as String?, platform: ReleasePlatform.fromJson(json['platform'] as String), arch: json['arch'] as String, appId: json['app_id'] as String, @@ -56,9 +54,6 @@ class PatchCheckRequest { /// number. If omitted, the server returns the latest available. final int? patchNumber; - /// The current patch hash of the app. - final String? patchHash; - /// A platform to which a Shorebird release can be deployed. final ReleasePlatform platform; @@ -85,7 +80,6 @@ class PatchCheckRequest { return { 'release_version': releaseVersion, 'patch_number': patchNumber, - 'patch_hash': patchHash, 'platform': platform.toJson(), 'arch': arch, 'app_id': appId, @@ -99,7 +93,6 @@ class PatchCheckRequest { int get hashCode => Object.hashAll([ releaseVersion, patchNumber, - patchHash, platform, arch, appId, @@ -114,7 +107,6 @@ class PatchCheckRequest { return other is PatchCheckRequest && releaseVersion == other.releaseVersion && patchNumber == other.patchNumber && - patchHash == other.patchHash && platform == other.platform && arch == other.arch && appId == other.appId && diff --git a/packages/shorebird_code_push_protocol/lib/src/models/activity_heatmap_cell.dart b/packages/shorebird_code_push_protocol/lib/src/models/activity_heatmap_cell.dart new file mode 100644 index 00000000..ffe82d45 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/activity_heatmap_cell.dart @@ -0,0 +1,76 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; + +/// {@template activity_heatmap_cell} +/// Average number of distinct active devices during one (UTC day-of-week, +/// UTC hour-of-day) cell, averaged across every occurrence of that weekday +/// in the lookback window (occurrences with no activity count as zero). +/// {@endtemplate} +@immutable +class ActivityHeatmapCell { + /// {@macro activity_heatmap_cell} + const ActivityHeatmapCell({ + required this.dayOfWeekUtc, + required this.hourUtc, + required this.averageActiveDevices, + }); + + /// Converts a `Map` to an [ActivityHeatmapCell]. + factory ActivityHeatmapCell.fromJson(Map json) { + return parseFromJson( + 'ActivityHeatmapCell', + json, + () => ActivityHeatmapCell( + dayOfWeekUtc: json['day_of_week_utc'] as int, + hourUtc: json['hour_utc'] as int, + averageActiveDevices: (json['average_active_devices'] as num) + .toDouble(), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static ActivityHeatmapCell? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return ActivityHeatmapCell.fromJson(json); + } + + /// Day of week in UTC, 1–7 where 1 = Sunday and 7 = Saturday. + final int dayOfWeekUtc; + + /// Hour of day in UTC, 0–23. + final int hourUtc; + + /// Mean distinct active devices seen during this UTC weekday-hour, + /// averaged over the weekday's occurrences in the window with implicit + /// zeros included. + final double averageActiveDevices; + + /// Converts an [ActivityHeatmapCell] to a `Map`. + Map toJson() { + return { + 'day_of_week_utc': dayOfWeekUtc, + 'hour_utc': hourUtc, + 'average_active_devices': averageActiveDevices, + }; + } + + @override + int get hashCode => Object.hashAll([ + dayOfWeekUtc, + hourUtc, + averageActiveDevices, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ActivityHeatmapCell && + dayOfWeekUtc == other.dayOfWeekUtc && + hourUtc == other.hourUtc && + averageActiveDevices == other.averageActiveDevices; + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/artifact_upload_method.dart b/packages/shorebird_code_push_protocol/lib/src/models/artifact_upload_method.dart new file mode 100644 index 00000000..9ccca14c --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/artifact_upload_method.dart @@ -0,0 +1,44 @@ +/// How a client should upload an artifact's bytes to storage. +/// +/// Returned by the create-artifact endpoints so the client knows how to use the +/// `url` it was handed. Absent (null) on responses from older servers, which +/// always implied [multipart]. +enum ArtifactUploadMethod { + /// Legacy single `multipart/form-data` POST of the file to a signed URL. + multipart._('multipart'), + + /// Resumable upload: PUT the bytes (chunked, with `Content-Range`) to a + /// server-initiated GCS resumable session URI given in `url`. The session is + /// size-bound at initiation, so GCS rejects an oversized upload. + resumable._('resumable'); + + const ArtifactUploadMethod._(this.value); + + /// Creates an [ArtifactUploadMethod] from a json value. + factory ArtifactUploadMethod.fromJson(String json) { + return ArtifactUploadMethod.values.firstWhere( + (value) => value.value == json, + orElse: () => + throw FormatException('Unknown ArtifactUploadMethod value: $json'), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static ArtifactUploadMethod? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return ArtifactUploadMethod.fromJson(json); + } + + /// The wire value of the enum, used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_downloads_parameter2.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_downloads_parameter2.dart new file mode 100644 index 00000000..bc91903e --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_downloads_parameter2.dart @@ -0,0 +1,37 @@ +enum GetAppPatchDownloadsParameter2 { + hour._('hour'), + day._('day'), + week._('week'); + + const GetAppPatchDownloadsParameter2._(this.value); + + /// Creates a GetAppPatchDownloadsParameter2 from a json value. + factory GetAppPatchDownloadsParameter2.fromJson(String json) { + return GetAppPatchDownloadsParameter2.values.firstWhere( + (value) => value.value == json, + orElse: () => throw FormatException( + 'Unknown GetAppPatchDownloadsParameter2 value: $json', + ), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static GetAppPatchDownloadsParameter2? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return GetAppPatchDownloadsParameter2.fromJson(json); + } + + /// The value of the enum. This is the exact value + /// from the OpenAPI spec and will be used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_downloads_parameter3.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_downloads_parameter3.dart new file mode 100644 index 00000000..ec6ac80d --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_downloads_parameter3.dart @@ -0,0 +1,35 @@ +enum GetAppPatchDownloadsParameter3 { + release._('release'); + + const GetAppPatchDownloadsParameter3._(this.value); + + /// Creates a GetAppPatchDownloadsParameter3 from a json value. + factory GetAppPatchDownloadsParameter3.fromJson(String json) { + return GetAppPatchDownloadsParameter3.values.firstWhere( + (value) => value.value == json, + orElse: () => throw FormatException( + 'Unknown GetAppPatchDownloadsParameter3 value: $json', + ), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static GetAppPatchDownloadsParameter3? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return GetAppPatchDownloadsParameter3.fromJson(json); + } + + /// The value of the enum. This is the exact value + /// from the OpenAPI spec and will be used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_installs_parameter2.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_installs_parameter2.dart new file mode 100644 index 00000000..54f79e0f --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_installs_parameter2.dart @@ -0,0 +1,37 @@ +enum GetAppPatchInstallsParameter2 { + hour._('hour'), + day._('day'), + week._('week'); + + const GetAppPatchInstallsParameter2._(this.value); + + /// Creates a GetAppPatchInstallsParameter2 from a json value. + factory GetAppPatchInstallsParameter2.fromJson(String json) { + return GetAppPatchInstallsParameter2.values.firstWhere( + (value) => value.value == json, + orElse: () => throw FormatException( + 'Unknown GetAppPatchInstallsParameter2 value: $json', + ), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static GetAppPatchInstallsParameter2? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return GetAppPatchInstallsParameter2.fromJson(json); + } + + /// The value of the enum. This is the exact value + /// from the OpenAPI spec and will be used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_installs_parameter3.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_installs_parameter3.dart new file mode 100644 index 00000000..9a620112 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_app_patch_installs_parameter3.dart @@ -0,0 +1,35 @@ +enum GetAppPatchInstallsParameter3 { + release._('release'); + + const GetAppPatchInstallsParameter3._(this.value); + + /// Creates a GetAppPatchInstallsParameter3 from a json value. + factory GetAppPatchInstallsParameter3.fromJson(String json) { + return GetAppPatchInstallsParameter3.values.firstWhere( + (value) => value.value == json, + orElse: () => throw FormatException( + 'Unknown GetAppPatchInstallsParameter3 value: $json', + ), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static GetAppPatchInstallsParameter3? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return GetAppPatchInstallsParameter3.fromJson(json); + } + + /// The value of the enum. This is the exact value + /// from the OpenAPI spec and will be used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_downloads_parameter2.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_downloads_parameter2.dart new file mode 100644 index 00000000..1bbd5a66 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_downloads_parameter2.dart @@ -0,0 +1,37 @@ +enum GetReleasePatchDownloadsParameter2 { + hour._('hour'), + day._('day'), + week._('week'); + + const GetReleasePatchDownloadsParameter2._(this.value); + + /// Creates a GetReleasePatchDownloadsParameter2 from a json value. + factory GetReleasePatchDownloadsParameter2.fromJson(String json) { + return GetReleasePatchDownloadsParameter2.values.firstWhere( + (value) => value.value == json, + orElse: () => throw FormatException( + 'Unknown GetReleasePatchDownloadsParameter2 value: $json', + ), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static GetReleasePatchDownloadsParameter2? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return GetReleasePatchDownloadsParameter2.fromJson(json); + } + + /// The value of the enum. This is the exact value + /// from the OpenAPI spec and will be used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_downloads_parameter3.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_downloads_parameter3.dart new file mode 100644 index 00000000..e011735b --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_downloads_parameter3.dart @@ -0,0 +1,35 @@ +enum GetReleasePatchDownloadsParameter3 { + patch._('patch'); + + const GetReleasePatchDownloadsParameter3._(this.value); + + /// Creates a GetReleasePatchDownloadsParameter3 from a json value. + factory GetReleasePatchDownloadsParameter3.fromJson(String json) { + return GetReleasePatchDownloadsParameter3.values.firstWhere( + (value) => value.value == json, + orElse: () => throw FormatException( + 'Unknown GetReleasePatchDownloadsParameter3 value: $json', + ), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static GetReleasePatchDownloadsParameter3? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return GetReleasePatchDownloadsParameter3.fromJson(json); + } + + /// The value of the enum. This is the exact value + /// from the OpenAPI spec and will be used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_installs_parameter2.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_installs_parameter2.dart new file mode 100644 index 00000000..a6a7d6a9 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_installs_parameter2.dart @@ -0,0 +1,37 @@ +enum GetReleasePatchInstallsParameter2 { + hour._('hour'), + day._('day'), + week._('week'); + + const GetReleasePatchInstallsParameter2._(this.value); + + /// Creates a GetReleasePatchInstallsParameter2 from a json value. + factory GetReleasePatchInstallsParameter2.fromJson(String json) { + return GetReleasePatchInstallsParameter2.values.firstWhere( + (value) => value.value == json, + orElse: () => throw FormatException( + 'Unknown GetReleasePatchInstallsParameter2 value: $json', + ), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static GetReleasePatchInstallsParameter2? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return GetReleasePatchInstallsParameter2.fromJson(json); + } + + /// The value of the enum. This is the exact value + /// from the OpenAPI spec and will be used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_installs_parameter3.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_installs_parameter3.dart new file mode 100644 index 00000000..a23bdbec --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_release_patch_installs_parameter3.dart @@ -0,0 +1,35 @@ +enum GetReleasePatchInstallsParameter3 { + patch._('patch'); + + const GetReleasePatchInstallsParameter3._(this.value); + + /// Creates a GetReleasePatchInstallsParameter3 from a json value. + factory GetReleasePatchInstallsParameter3.fromJson(String json) { + return GetReleasePatchInstallsParameter3.values.firstWhere( + (value) => value.value == json, + orElse: () => throw FormatException( + 'Unknown GetReleasePatchInstallsParameter3 value: $json', + ), + ); + } + + /// Convenience to create a nullable type from a nullable json value. + /// Useful when parsing optional fields. + static GetReleasePatchInstallsParameter3? maybeFromJson(String? json) { + if (json == null) { + return null; + } + return GetReleasePatchInstallsParameter3.fromJson(json); + } + + /// The value of the enum. This is the exact value + /// from the OpenAPI spec and will be used for network transport. + final String value; + + /// Converts the enum to its json value. + String toJson() => value; + + /// Returns the string form of the enum. + @override + String toString() => value; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_unique_users_parameter3.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_unique_users_parameter3.dart index 97daea87..8812542b 100644 --- a/packages/shorebird_code_push_protocol/lib/src/models/get_unique_users_parameter3.dart +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_unique_users_parameter3.dart @@ -1,5 +1,6 @@ enum GetUniqueUsersParameter3 { - platform._('platform'); + platform._('platform'), + releaseVersion._('release_version'); const GetUniqueUsersParameter3._(this.value); diff --git a/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_breakdown_entry.dart b/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_breakdown_entry.dart new file mode 100644 index 00000000..a1236c2f --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_breakdown_entry.dart @@ -0,0 +1,90 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/patch_metric_time_series_entry.dart'; + +/// {@template patch_metric_breakdown_entry} +/// A patch metric for one value of the `group_by` dimension (one release +/// at app scope, or one patch at release scope), optionally with its own +/// time series. +/// {@endtemplate} +@immutable +class PatchMetricBreakdownEntry { + /// {@macro patch_metric_breakdown_entry} + const PatchMetricBreakdownEntry({ + required this.groupBy, + required this.groupValue, + required this.count, + this.timeSeries, + }); + + /// Converts a `Map` to a [PatchMetricBreakdownEntry]. + factory PatchMetricBreakdownEntry.fromJson(Map json) { + return parseFromJson( + 'PatchMetricBreakdownEntry', + json, + () => PatchMetricBreakdownEntry( + groupBy: json['group_by'] as String, + groupValue: json['group_value'] as String, + count: json['count'] as int, + timeSeries: (json['time_series'] as List?) + ?.map( + (e) => PatchMetricTimeSeriesEntry.fromJson( + e as Map, + ), + ) + .toList(), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static PatchMetricBreakdownEntry? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return PatchMetricBreakdownEntry.fromJson(json); + } + + /// The dimension this entry breaks down by ("release" or "patch"). + final String groupBy; + + /// The value within `group_by`: the release version, or the patch + /// number as a string. + final String groupValue; + + /// The summed count for this group over the window. + final int count; + + /// Per-bucket series for this group, present only when a `granularity` + /// was requested; otherwise null. + final List? timeSeries; + + /// Converts a [PatchMetricBreakdownEntry] to a `Map`. + Map toJson() { + return { + 'group_by': groupBy, + 'group_value': groupValue, + 'count': count, + 'time_series': timeSeries?.map((e) => e.toJson()).toList(), + }; + } + + @override + int get hashCode => Object.hashAll([ + groupBy, + groupValue, + count, + listHash(timeSeries), + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is PatchMetricBreakdownEntry && + groupBy == other.groupBy && + groupValue == other.groupValue && + count == other.count && + listsEqual(timeSeries, other.timeSeries); + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_current_window.dart b/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_current_window.dart new file mode 100644 index 00000000..049534c7 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_current_window.dart @@ -0,0 +1,108 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/metrics_range.dart'; +import 'package:shorebird_code_push_protocol/src/models/patch_metric_breakdown_entry.dart'; +import 'package:shorebird_code_push_protocol/src/models/patch_metric_time_series_entry.dart'; + +/// {@template patch_metric_current_window} +/// The `current` window of the patch-metric envelope: the base window atom +/// plus the optional `breakdown`. Only `current` carries a breakdown — no +/// chart renders a previous-window breakdown, so the asymmetry is declared +/// in the contract rather than left as an optional-but-never-populated +/// field. +/// {@endtemplate} +@immutable +class PatchMetricCurrentWindow { + /// {@macro patch_metric_current_window} + const PatchMetricCurrentWindow({ + required this.count, + required this.range, + this.timeSeries, + this.breakdown, + }); + + /// Converts a `Map` to a [PatchMetricCurrentWindow]. + factory PatchMetricCurrentWindow.fromJson(Map json) { + return parseFromJson( + 'PatchMetricCurrentWindow', + json, + () => PatchMetricCurrentWindow( + count: json['count'] as int, + range: MetricsRange.fromJson(json['range'] as Map), + timeSeries: (json['time_series'] as List?) + ?.map( + (e) => PatchMetricTimeSeriesEntry.fromJson( + e as Map, + ), + ) + .toList(), + breakdown: (json['breakdown'] as List?) + ?.map( + (e) => + PatchMetricBreakdownEntry.fromJson(e as Map), + ) + .toList(), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static PatchMetricCurrentWindow? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return PatchMetricCurrentWindow.fromJson(json); + } + + /// The summed count (installs or downloads) over this window. Unlike + /// the HLL-based metrics, per-bucket `time_series` values sum to this + /// total. + final int count; + + /// The effective (post-default, post-clamp) window a metrics response — + /// or one window of a metrics envelope — covers. Always echoed by the + /// server; clients must treat it as authoritative rather than reusing + /// the requested range. + final MetricsRange range; + + /// Per-bucket series for this window, present only when a + /// `granularity` was requested; otherwise null. On `previous`, also + /// null when the prior window reaches past the plan's metrics-history + /// horizon (the total is still present — only the granular overlay is + /// withheld). Empty buckets are omitted — gap-fill against this + /// window's `range`. + final List? timeSeries; + + /// Per-group counts for this window, present only when a + /// `group_by` was requested; otherwise null. + final List? breakdown; + + /// Converts a [PatchMetricCurrentWindow] to a `Map`. + Map toJson() { + return { + 'count': count, + 'range': range.toJson(), + 'time_series': timeSeries?.map((e) => e.toJson()).toList(), + 'breakdown': breakdown?.map((e) => e.toJson()).toList(), + }; + } + + @override + int get hashCode => Object.hashAll([ + count, + range, + listHash(timeSeries), + listHash(breakdown), + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is PatchMetricCurrentWindow && + count == other.count && + range == other.range && + listsEqual(timeSeries, other.timeSeries) && + listsEqual(breakdown, other.breakdown); + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_time_series_entry.dart b/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_time_series_entry.dart new file mode 100644 index 00000000..5dd4b5d3 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_time_series_entry.dart @@ -0,0 +1,64 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; + +/// {@template patch_metric_time_series_entry} +/// One bucket of a patch-metric time series: the summed count (installs or +/// downloads, per the endpoint) in the bucket starting at `period`. +/// {@endtemplate} +@immutable +class PatchMetricTimeSeriesEntry { + /// {@macro patch_metric_time_series_entry} + const PatchMetricTimeSeriesEntry({ + required this.period, + required this.count, + }); + + /// Converts a `Map` to a [PatchMetricTimeSeriesEntry]. + factory PatchMetricTimeSeriesEntry.fromJson(Map json) { + return parseFromJson( + 'PatchMetricTimeSeriesEntry', + json, + () => PatchMetricTimeSeriesEntry( + period: DateTime.parse(json['period'] as String), + count: json['count'] as int, + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static PatchMetricTimeSeriesEntry? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return PatchMetricTimeSeriesEntry.fromJson(json); + } + + /// The bucket start (UTC). + final DateTime period; + + /// The summed count in this bucket. + final int count; + + /// Converts a [PatchMetricTimeSeriesEntry] to a `Map`. + Map toJson() { + return { + 'period': period.toIso8601String(), + 'count': count, + }; + } + + @override + int get hashCode => Object.hashAll([ + period, + count, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is PatchMetricTimeSeriesEntry && + period == other.period && + count == other.count; + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_window.dart b/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_window.dart new file mode 100644 index 00000000..f6fe2caf --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/patch_metric_window.dart @@ -0,0 +1,92 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/metrics_range.dart'; +import 'package:shorebird_code_push_protocol/src/models/patch_metric_time_series_entry.dart'; + +/// {@template patch_metric_window} +/// One window of the patch-metric envelope: the summed count over the +/// window's effective range, with a per-bucket series when a `granularity` +/// was requested. This base atom is the full shape of `previous`; +/// `current` extends it (see PatchMetricCurrentWindow). +/// {@endtemplate} +@immutable +class PatchMetricWindow { + /// {@macro patch_metric_window} + const PatchMetricWindow({ + required this.count, + required this.range, + this.timeSeries, + }); + + /// Converts a `Map` to a [PatchMetricWindow]. + factory PatchMetricWindow.fromJson(Map json) { + return parseFromJson( + 'PatchMetricWindow', + json, + () => PatchMetricWindow( + count: json['count'] as int, + range: MetricsRange.fromJson(json['range'] as Map), + timeSeries: (json['time_series'] as List?) + ?.map( + (e) => PatchMetricTimeSeriesEntry.fromJson( + e as Map, + ), + ) + .toList(), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static PatchMetricWindow? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return PatchMetricWindow.fromJson(json); + } + + /// The summed count (installs or downloads) over this window. Unlike + /// the HLL-based metrics, per-bucket `time_series` values sum to this + /// total. + final int count; + + /// The effective (post-default, post-clamp) window a metrics response — + /// or one window of a metrics envelope — covers. Always echoed by the + /// server; clients must treat it as authoritative rather than reusing + /// the requested range. + final MetricsRange range; + + /// Per-bucket series for this window, present only when a + /// `granularity` was requested; otherwise null. On `previous`, also + /// null when the prior window reaches past the plan's metrics-history + /// horizon (the total is still present — only the granular overlay is + /// withheld). Empty buckets are omitted — gap-fill against this + /// window's `range`. + final List? timeSeries; + + /// Converts a [PatchMetricWindow] to a `Map`. + Map toJson() { + return { + 'count': count, + 'range': range.toJson(), + 'time_series': timeSeries?.map((e) => e.toJson()).toList(), + }; + } + + @override + int get hashCode => Object.hashAll([ + count, + range, + listHash(timeSeries), + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is PatchMetricWindow && + count == other.count && + range == other.range && + listsEqual(timeSeries, other.timeSeries); + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/unique_users_breakdown_entry.dart b/packages/shorebird_code_push_protocol/lib/src/models/unique_users_breakdown_entry.dart index bdeffa4e..34d69bd8 100644 --- a/packages/shorebird_code_push_protocol/lib/src/models/unique_users_breakdown_entry.dart +++ b/packages/shorebird_code_push_protocol/lib/src/models/unique_users_breakdown_entry.dart @@ -48,7 +48,10 @@ class UniqueUsersBreakdownEntry { /// The dimension this entry breaks down by (e.g. "platform"). final String groupBy; - /// The value within `group_by` (e.g. "android"). + /// The value within `group_by` (e.g. "android"). For + /// `group_by=release_version`, the empty string is the + /// unknown-version group — devices whose client is too old to + /// report a release version. final String groupValue; /// Distinct active devices for this group over the window (an HLL diff --git a/packages/shorebird_code_push_protocol/pubspec.yaml b/packages/shorebird_code_push_protocol/pubspec.yaml index e8ed1e05..102c7a56 100644 --- a/packages/shorebird_code_push_protocol/pubspec.yaml +++ b/packages/shorebird_code_push_protocol/pubspec.yaml @@ -11,11 +11,11 @@ environment: dependencies: collection: ^1.19.1 - meta: ^1.16.0 + meta: ^1.18.3 dev_dependencies: mocktail: ^1.0.5 # Only used by `tool/gen.dart` to regenerate lib/src/ from the OpenAPI # spec. - space_gen: ^1.2.2 - test: ^1.31.1 + space_gen: ^1.3.0 + test: ^1.31.2 diff --git a/packages/shorebird_code_push_protocol/test/generated/messages/get_activity_heatmap/get_activity_heatmap_response_test.dart b/packages/shorebird_code_push_protocol/test/generated/messages/get_activity_heatmap/get_activity_heatmap_response_test.dart new file mode 100644 index 00000000..a766b973 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/messages/get_activity_heatmap/get_activity_heatmap_response_test.dart @@ -0,0 +1,39 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetActivityHeatmapResponse', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetActivityHeatmapResponse( + cells: [ + ActivityHeatmapCell( + dayOfWeekUtc: 0, + hourUtc: 0, + averageActiveDevices: 0, + ), + ], + busiestDayOfWeekUtc: 0, + busiestHourUtc: 0, + lookbackDays: 0, + asOf: DateTime.utc(2024), + ); + final parsed = GetActivityHeatmapResponse.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetActivityHeatmapResponse.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetActivityHeatmapResponse.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/messages/get_new_devices/get_new_devices_response_test.dart b/packages/shorebird_code_push_protocol/test/generated/messages/get_new_devices/get_new_devices_response_test.dart new file mode 100644 index 00000000..55d20019 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/messages/get_new_devices/get_new_devices_response_test.dart @@ -0,0 +1,30 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetNewDevicesResponse', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetNewDevicesResponse( + current: 0, + previous: 0, + windowDays: 0, + asOf: DateTime.utc(2024), + ); + final parsed = GetNewDevicesResponse.maybeFromJson(instance.toJson()); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetNewDevicesResponse.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetNewDevicesResponse.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/messages/get_patch_metric_response_test.dart b/packages/shorebird_code_push_protocol/test/generated/messages/get_patch_metric_response_test.dart new file mode 100644 index 00000000..b4a99253 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/messages/get_patch_metric_response_test.dart @@ -0,0 +1,35 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetPatchMetricResponse', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetPatchMetricResponse( + asOf: DateTime.utc(2024), + granularity: 'example', + current: PatchMetricCurrentWindow( + count: 0, + range: MetricsRange( + start: DateTime.utc(2024), + end: DateTime.utc(2024), + ), + ), + ); + final parsed = GetPatchMetricResponse.maybeFromJson(instance.toJson()); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetPatchMetricResponse.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetPatchMetricResponse.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/activity_heatmap_cell_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/activity_heatmap_cell_test.dart new file mode 100644 index 00000000..05c447d3 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/activity_heatmap_cell_test.dart @@ -0,0 +1,29 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('ActivityHeatmapCell', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = ActivityHeatmapCell( + dayOfWeekUtc: 0, + hourUtc: 0, + averageActiveDevices: 0, + ); + final parsed = ActivityHeatmapCell.maybeFromJson(instance.toJson()); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(ActivityHeatmapCell.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => ActivityHeatmapCell.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_downloads_parameter2_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_downloads_parameter2_test.dart new file mode 100644 index 00000000..f6e3691d --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_downloads_parameter2_test.dart @@ -0,0 +1,44 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetAppPatchDownloadsParameter2', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetAppPatchDownloadsParameter2.values.first; + final parsed = GetAppPatchDownloadsParameter2.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetAppPatchDownloadsParameter2.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetAppPatchDownloadsParameter2.maybeFromJson( + '__invalid_enum_value__', + ), + throwsFormatException, + ); + }); + + test('toString matches toJson for every value', () { + for (final value in GetAppPatchDownloadsParameter2.values) { + expect(value.toString(), equals(value.toJson())); + } + }); + + test('fromJson round-trips every value', () { + for (final value in GetAppPatchDownloadsParameter2.values) { + expect( + GetAppPatchDownloadsParameter2.fromJson(value.toJson()), + equals(value), + ); + } + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_downloads_parameter3_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_downloads_parameter3_test.dart new file mode 100644 index 00000000..5f3f8c1d --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_downloads_parameter3_test.dart @@ -0,0 +1,44 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetAppPatchDownloadsParameter3', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetAppPatchDownloadsParameter3.values.first; + final parsed = GetAppPatchDownloadsParameter3.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetAppPatchDownloadsParameter3.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetAppPatchDownloadsParameter3.maybeFromJson( + '__invalid_enum_value__', + ), + throwsFormatException, + ); + }); + + test('toString matches toJson for every value', () { + for (final value in GetAppPatchDownloadsParameter3.values) { + expect(value.toString(), equals(value.toJson())); + } + }); + + test('fromJson round-trips every value', () { + for (final value in GetAppPatchDownloadsParameter3.values) { + expect( + GetAppPatchDownloadsParameter3.fromJson(value.toJson()), + equals(value), + ); + } + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_installs_parameter2_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_installs_parameter2_test.dart new file mode 100644 index 00000000..756af06b --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_installs_parameter2_test.dart @@ -0,0 +1,44 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetAppPatchInstallsParameter2', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetAppPatchInstallsParameter2.values.first; + final parsed = GetAppPatchInstallsParameter2.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetAppPatchInstallsParameter2.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetAppPatchInstallsParameter2.maybeFromJson( + '__invalid_enum_value__', + ), + throwsFormatException, + ); + }); + + test('toString matches toJson for every value', () { + for (final value in GetAppPatchInstallsParameter2.values) { + expect(value.toString(), equals(value.toJson())); + } + }); + + test('fromJson round-trips every value', () { + for (final value in GetAppPatchInstallsParameter2.values) { + expect( + GetAppPatchInstallsParameter2.fromJson(value.toJson()), + equals(value), + ); + } + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_installs_parameter3_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_installs_parameter3_test.dart new file mode 100644 index 00000000..8117adf4 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_app_patch_installs_parameter3_test.dart @@ -0,0 +1,44 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetAppPatchInstallsParameter3', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetAppPatchInstallsParameter3.values.first; + final parsed = GetAppPatchInstallsParameter3.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetAppPatchInstallsParameter3.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetAppPatchInstallsParameter3.maybeFromJson( + '__invalid_enum_value__', + ), + throwsFormatException, + ); + }); + + test('toString matches toJson for every value', () { + for (final value in GetAppPatchInstallsParameter3.values) { + expect(value.toString(), equals(value.toJson())); + } + }); + + test('fromJson round-trips every value', () { + for (final value in GetAppPatchInstallsParameter3.values) { + expect( + GetAppPatchInstallsParameter3.fromJson(value.toJson()), + equals(value), + ); + } + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_downloads_parameter2_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_downloads_parameter2_test.dart new file mode 100644 index 00000000..ebe24eec --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_downloads_parameter2_test.dart @@ -0,0 +1,44 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetReleasePatchDownloadsParameter2', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetReleasePatchDownloadsParameter2.values.first; + final parsed = GetReleasePatchDownloadsParameter2.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetReleasePatchDownloadsParameter2.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetReleasePatchDownloadsParameter2.maybeFromJson( + '__invalid_enum_value__', + ), + throwsFormatException, + ); + }); + + test('toString matches toJson for every value', () { + for (final value in GetReleasePatchDownloadsParameter2.values) { + expect(value.toString(), equals(value.toJson())); + } + }); + + test('fromJson round-trips every value', () { + for (final value in GetReleasePatchDownloadsParameter2.values) { + expect( + GetReleasePatchDownloadsParameter2.fromJson(value.toJson()), + equals(value), + ); + } + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_downloads_parameter3_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_downloads_parameter3_test.dart new file mode 100644 index 00000000..40c48f23 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_downloads_parameter3_test.dart @@ -0,0 +1,44 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetReleasePatchDownloadsParameter3', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetReleasePatchDownloadsParameter3.values.first; + final parsed = GetReleasePatchDownloadsParameter3.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetReleasePatchDownloadsParameter3.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetReleasePatchDownloadsParameter3.maybeFromJson( + '__invalid_enum_value__', + ), + throwsFormatException, + ); + }); + + test('toString matches toJson for every value', () { + for (final value in GetReleasePatchDownloadsParameter3.values) { + expect(value.toString(), equals(value.toJson())); + } + }); + + test('fromJson round-trips every value', () { + for (final value in GetReleasePatchDownloadsParameter3.values) { + expect( + GetReleasePatchDownloadsParameter3.fromJson(value.toJson()), + equals(value), + ); + } + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_installs_parameter2_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_installs_parameter2_test.dart new file mode 100644 index 00000000..b3a26672 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_installs_parameter2_test.dart @@ -0,0 +1,44 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetReleasePatchInstallsParameter2', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetReleasePatchInstallsParameter2.values.first; + final parsed = GetReleasePatchInstallsParameter2.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetReleasePatchInstallsParameter2.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetReleasePatchInstallsParameter2.maybeFromJson( + '__invalid_enum_value__', + ), + throwsFormatException, + ); + }); + + test('toString matches toJson for every value', () { + for (final value in GetReleasePatchInstallsParameter2.values) { + expect(value.toString(), equals(value.toJson())); + } + }); + + test('fromJson round-trips every value', () { + for (final value in GetReleasePatchInstallsParameter2.values) { + expect( + GetReleasePatchInstallsParameter2.fromJson(value.toJson()), + equals(value), + ); + } + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_installs_parameter3_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_installs_parameter3_test.dart new file mode 100644 index 00000000..41540f90 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_release_patch_installs_parameter3_test.dart @@ -0,0 +1,44 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetReleasePatchInstallsParameter3', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetReleasePatchInstallsParameter3.values.first; + final parsed = GetReleasePatchInstallsParameter3.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetReleasePatchInstallsParameter3.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetReleasePatchInstallsParameter3.maybeFromJson( + '__invalid_enum_value__', + ), + throwsFormatException, + ); + }); + + test('toString matches toJson for every value', () { + for (final value in GetReleasePatchInstallsParameter3.values) { + expect(value.toString(), equals(value.toJson())); + } + }); + + test('fromJson round-trips every value', () { + for (final value in GetReleasePatchInstallsParameter3.values) { + expect( + GetReleasePatchInstallsParameter3.fromJson(value.toJson()), + equals(value), + ); + } + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_breakdown_entry_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_breakdown_entry_test.dart new file mode 100644 index 00000000..6c1fa3e5 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_breakdown_entry_test.dart @@ -0,0 +1,31 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('PatchMetricBreakdownEntry', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = PatchMetricBreakdownEntry( + groupBy: 'example', + groupValue: 'example', + count: 0, + ); + final parsed = PatchMetricBreakdownEntry.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(PatchMetricBreakdownEntry.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => PatchMetricBreakdownEntry.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_current_window_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_current_window_test.dart new file mode 100644 index 00000000..bc41d3fc --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_current_window_test.dart @@ -0,0 +1,31 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('PatchMetricCurrentWindow', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = PatchMetricCurrentWindow( + count: 0, + range: MetricsRange( + start: DateTime.utc(2024), + end: DateTime.utc(2024), + ), + ); + final parsed = PatchMetricCurrentWindow.maybeFromJson(instance.toJson()); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(PatchMetricCurrentWindow.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => PatchMetricCurrentWindow.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_time_series_entry_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_time_series_entry_test.dart new file mode 100644 index 00000000..5233fe80 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_time_series_entry_test.dart @@ -0,0 +1,30 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('PatchMetricTimeSeriesEntry', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = PatchMetricTimeSeriesEntry( + period: DateTime.utc(2024), + count: 0, + ); + final parsed = PatchMetricTimeSeriesEntry.maybeFromJson( + instance.toJson(), + ); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(PatchMetricTimeSeriesEntry.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => PatchMetricTimeSeriesEntry.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_window_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_window_test.dart new file mode 100644 index 00000000..02c0ebdd --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/patch_metric_window_test.dart @@ -0,0 +1,31 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('PatchMetricWindow', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = PatchMetricWindow( + count: 0, + range: MetricsRange( + start: DateTime.utc(2024), + end: DateTime.utc(2024), + ), + ); + final parsed = PatchMetricWindow.maybeFromJson(instance.toJson()); + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(PatchMetricWindow.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => PatchMetricWindow.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/src/messages/create_patch_artifact/create_patch_artifact_response_test.dart b/packages/shorebird_code_push_protocol/test/src/messages/create_patch_artifact/create_patch_artifact_response_test.dart index 999d478f..ab572f71 100644 --- a/packages/shorebird_code_push_protocol/test/src/messages/create_patch_artifact/create_patch_artifact_response_test.dart +++ b/packages/shorebird_code_push_protocol/test/src/messages/create_patch_artifact/create_patch_artifact_response_test.dart @@ -18,5 +18,21 @@ void main() { equals(request.toJson()), ); }); + + test('can be (de)serialized with a resumable upload method', () { + const response = CreatePatchArtifactResponse( + id: 42, + patchId: 1, + arch: 'arm64', + platform: ReleasePlatform.android, + hash: '1234', + size: 9876, + url: 'https://example.com', + uploadMethod: ArtifactUploadMethod.resumable, + ); + final decoded = CreatePatchArtifactResponse.fromJson(response.toJson()); + expect(decoded.toJson(), equals(response.toJson())); + expect(decoded.uploadMethod, equals(ArtifactUploadMethod.resumable)); + }); }); } diff --git a/packages/shorebird_code_push_protocol/test/src/messages/create_release_artifact/create_release_artifact_response_test.dart b/packages/shorebird_code_push_protocol/test/src/messages/create_release_artifact/create_release_artifact_response_test.dart index 6b4c5d24..f214466d 100644 --- a/packages/shorebird_code_push_protocol/test/src/messages/create_release_artifact/create_release_artifact_response_test.dart +++ b/packages/shorebird_code_push_protocol/test/src/messages/create_release_artifact/create_release_artifact_response_test.dart @@ -18,5 +18,21 @@ void main() { equals(response.toJson()), ); }); + + test('can be (de)serialized with a resumable upload method', () { + const response = CreateReleaseArtifactResponse( + id: 42, + releaseId: 1, + arch: 'arm64', + platform: ReleasePlatform.android, + hash: '1234', + size: 9876, + url: 'https://example.com', + uploadMethod: ArtifactUploadMethod.resumable, + ); + final decoded = CreateReleaseArtifactResponse.fromJson(response.toJson()); + expect(decoded.toJson(), equals(response.toJson())); + expect(decoded.uploadMethod, equals(ArtifactUploadMethod.resumable)); + }); }); } diff --git a/packages/shorebird_code_push_protocol/test/src/messages/patch_check/patch_check_request_test.dart b/packages/shorebird_code_push_protocol/test/src/messages/patch_check/patch_check_request_test.dart index 747b70bd..536f9453 100644 --- a/packages/shorebird_code_push_protocol/test/src/messages/patch_check/patch_check_request_test.dart +++ b/packages/shorebird_code_push_protocol/test/src/messages/patch_check/patch_check_request_test.dart @@ -6,7 +6,6 @@ void main() { const request = PatchCheckRequest( releaseVersion: '1', patchNumber: 2, - patchHash: '3', platform: ReleasePlatform.android, arch: 'arm64', appId: 'app_123', @@ -26,7 +25,6 @@ void main() { const copy = PatchCheckRequest( releaseVersion: '1', patchNumber: 2, - patchHash: '3', platform: ReleasePlatform.android, arch: 'arm64', appId: 'app_123', @@ -37,7 +35,6 @@ void main() { const different = PatchCheckRequest( releaseVersion: '1', patchNumber: 2, - patchHash: '3', platform: ReleasePlatform.android, arch: 'arm64', appId: 'app_123', diff --git a/packages/shorebird_code_push_protocol/test/src/models/artifact_upload_method_test.dart b/packages/shorebird_code_push_protocol/test/src/models/artifact_upload_method_test.dart new file mode 100644 index 00000000..429f8a70 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/src/models/artifact_upload_method_test.dart @@ -0,0 +1,28 @@ +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group(ArtifactUploadMethod, () { + test('round-trips through json', () { + for (final method in ArtifactUploadMethod.values) { + expect(ArtifactUploadMethod.fromJson(method.toJson()), equals(method)); + } + }); + + test('maybeFromJson returns null for null', () { + expect(ArtifactUploadMethod.maybeFromJson(null), isNull); + }); + + test('fromJson throws on an unknown value', () { + expect( + () => ArtifactUploadMethod.fromJson('nope'), + throwsFormatException, + ); + }); + + test('toString returns the wire value', () { + expect(ArtifactUploadMethod.multipart.toString(), 'multipart'); + expect(ArtifactUploadMethod.resumable.toString(), 'resumable'); + }); + }); +} diff --git a/packages/stripe_api/lib/src/stripe_api.dart b/packages/stripe_api/lib/src/stripe_api.dart index 31c540ea..cb038949 100644 --- a/packages/stripe_api/lib/src/stripe_api.dart +++ b/packages/stripe_api/lib/src/stripe_api.dart @@ -36,7 +36,10 @@ class StripeApi { final response = await _client.get(uri, headers: _authHeaders); if (response.statusCode != HttpStatus.ok) { - throw Exception('Failed to retrieve customer with id $customerId'); + throw StripeApiException.fromResponse( + response, + message: 'Failed to retrieve customer with id $customerId', + ); } return StripeCustomer.fromJson( @@ -55,8 +58,9 @@ class StripeApi { final response = await _client.get(uri, headers: _authHeaders); if (response.statusCode != HttpStatus.ok) { - throw Exception( - 'Failed to retrieve subscription with id $subscriptionId', + throw StripeApiException.fromResponse( + response, + message: 'Failed to retrieve subscription with id $subscriptionId', ); } @@ -96,10 +100,14 @@ class StripeApi { ); if (response.statusCode != HttpStatus.ok) { - throw Exception(''' + throw StripeApiException.fromResponse( + response, + message: + ''' Failed to report $value for customer $customerId. Error: ${response.body} -'''); +''', + ); } } @@ -146,10 +154,14 @@ ${response.body} final response = await _client.get(uri, headers: _authHeaders); if (response.statusCode != HttpStatus.ok) { - throw Exception(''' + throw StripeApiException.fromResponse( + response, + message: + ''' Failed to get paged response from $path with params $queryParameters. Error: ${response.body} -'''); +''', + ); } final pagedResponse = PagedResponse.fromJson( diff --git a/packages/stripe_api/lib/src/stripe_api_exception.dart b/packages/stripe_api/lib/src/stripe_api_exception.dart new file mode 100644 index 00000000..9267bdd4 --- /dev/null +++ b/packages/stripe_api/lib/src/stripe_api_exception.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// {@template stripe_api_exception} +/// An exception thrown when a request to the Stripe API returns a non-success +/// response. +/// +/// Carries the HTTP [statusCode] of the failed response so callers can +/// distinguish, for example, a missing resource (`404`) from rate limiting +/// (`429`). When the response body is a Stripe error object, the +/// machine-readable [code] (e.g. `resource_missing`, `rate_limit`) is also +/// surfaced. +/// +/// See https://docs.stripe.com/api/errors. +/// {@endtemplate} +class StripeApiException implements Exception { + /// {@macro stripe_api_exception} + StripeApiException({ + required this.statusCode, + required this.message, + this.code, + }); + + /// Builds a [StripeApiException] from a failed [response]. + /// + /// [message] is a human-readable description supplied by the caller and is + /// preserved verbatim. The Stripe error [code] is parsed from the response + /// body when it is a JSON error object. + /// + /// Never throws: a non-JSON or unexpected body (e.g. an HTML gateway error) + /// simply leaves [code] null. + factory StripeApiException.fromResponse( + http.Response response, { + required String message, + }) { + String? code; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final error = decoded['error']; + if (error is Map && error['code'] is String) { + code = error['code'] as String; + } + } + } on Object catch (_) { + // Non-JSON or unexpected body; the status code is still meaningful. + } + return StripeApiException( + statusCode: response.statusCode, + message: message, + code: code, + ); + } + + /// The HTTP status code of the failed response. + final int statusCode; + + /// The Stripe machine-readable error code (e.g. `resource_missing`), when the + /// response body was a Stripe error object; otherwise null. + final String? code; + + /// A human-readable description of the failure. + final String message; + + @override + String toString() => + 'StripeApiException($statusCode${code == null ? '' : ', $code'}): ' + '$message'; +} diff --git a/packages/stripe_api/lib/stripe_api.dart b/packages/stripe_api/lib/stripe_api.dart index f557cb47..7f5e4967 100644 --- a/packages/stripe_api/lib/stripe_api.dart +++ b/packages/stripe_api/lib/stripe_api.dart @@ -1,2 +1,3 @@ export 'src/models/models.dart'; export 'src/stripe_api.dart'; +export 'src/stripe_api_exception.dart'; diff --git a/packages/stripe_api/pubspec.yaml b/packages/stripe_api/pubspec.yaml index d00e1ec3..05776769 100644 --- a/packages/stripe_api/pubspec.yaml +++ b/packages/stripe_api/pubspec.yaml @@ -17,4 +17,4 @@ dev_dependencies: build_runner: ^2.15.0 json_serializable: ^6.13.2 mocktail: ^1.0.5 - test: ^1.31.1 + test: ^1.31.2 diff --git a/packages/stripe_api/test/src/stripe_api_test.dart b/packages/stripe_api/test/src/stripe_api_test.dart index c80fab2e..9ec57ae3 100644 --- a/packages/stripe_api/test/src/stripe_api_test.dart +++ b/packages/stripe_api/test/src/stripe_api_test.dart @@ -130,6 +130,32 @@ void main() { ).called(1); }); + test('throws StripeApiException carrying status and code on 404', () { + when( + () => httpClient.get(uri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response( + jsonEncode({ + 'error': { + 'type': 'invalid_request_error', + 'code': 'resource_missing', + 'message': "No such customer: 'cus_123'", + }, + }), + HttpStatus.notFound, + ), + ); + + expect( + () => stripeApi.fetchCustomer(customerId: 'cus_123'), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', HttpStatus.notFound) + .having((e) => e.code, 'code', 'resource_missing'), + ), + ); + }); + test('returns a customer on successful request', () async { when( () => httpClient.get(uri, headers: any(named: 'headers')), @@ -171,6 +197,36 @@ void main() { ).called(1); }); + test('throws StripeApiException carrying status 429 on rate limit', () { + when( + () => httpClient.get(uri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response( + jsonEncode({ + 'error': { + 'type': 'api_error', + 'code': 'rate_limit', + 'message': 'Too many requests', + }, + }), + HttpStatus.tooManyRequests, + ), + ); + + expect( + () => stripeApi.fetchSubscription(subscriptionId: subscriptionId), + throwsA( + isA() + .having( + (e) => e.statusCode, + 'statusCode', + HttpStatus.tooManyRequests, + ) + .having((e) => e.code, 'code', 'rate_limit'), + ), + ); + }); + test('returns a subscription on successful request', () async { when( () => httpClient.get(uri, headers: any(named: 'headers')), @@ -409,4 +465,58 @@ void main() { }); }); }); + + group(StripeApiException, () { + test('fromResponse parses the Stripe error code from a JSON body', () { + final exception = StripeApiException.fromResponse( + http.Response( + jsonEncode({ + 'error': { + 'code': 'resource_missing', + 'message': 'No such customer', + }, + }), + HttpStatus.notFound, + ), + message: 'fallback', + ); + + expect(exception.statusCode, HttpStatus.notFound); + expect(exception.code, 'resource_missing'); + expect(exception.message, 'fallback'); + }); + + test('fromResponse leaves code null on a non-JSON body', () { + final exception = StripeApiException.fromResponse( + http.Response('502 Bad Gateway', HttpStatus.badGateway), + message: 'fallback', + ); + + expect(exception.statusCode, HttpStatus.badGateway); + expect(exception.code, isNull); + expect(exception.message, 'fallback'); + }); + + test('fromResponse leaves code null when the body has no error object', () { + final exception = StripeApiException.fromResponse( + http.Response(jsonEncode({'ok': true}), HttpStatus.badRequest), + message: 'fallback', + ); + + expect(exception.code, isNull); + }); + + test('toString includes the status code and error code', () { + final exception = StripeApiException( + statusCode: HttpStatus.notFound, + message: 'No such customer', + code: 'resource_missing', + ); + + expect( + exception.toString(), + 'StripeApiException(404, resource_missing): No such customer', + ); + }); + }); } diff --git a/pubspec.lock b/pubspec.lock index 38c5dfee..f2104772 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "8d718c5c58904f9937290fd5dbf2d6a0e02456867706bfb6cd7b81d394e738d5" + sha256: "1b0e6a07425a3e460666e88bf1c949ccc7bb0116ad562ce94a1eca60fe820725" url: "https://pub.dev" source: hosted - version: "98.0.0" + version: "103.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "6141ad5d092d1e1d13929c0504658bbeccc1703505830d7c26e859908f5efc88" + sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e" url: "https://pub.dev" source: hosted - version: "12.0.0" + version: "13.3.0" archive: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: build - sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 url: "https://pub.dev" source: hosted - version: "4.0.5" + version: "4.0.6" build_config: dependency: transitive description: @@ -197,10 +197,10 @@ packages: dependency: transitive description: name: dart_style - sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" url: "https://pub.dev" source: hosted - version: "3.1.8" + version: "3.1.9" decimal: dependency: transitive description: @@ -213,10 +213,10 @@ packages: dependency: transitive description: name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.0" ffi: dependency: transitive description: @@ -277,10 +277,10 @@ packages: dependency: transitive description: name: googleapis_auth - sha256: "2a8895c3885197f96bb2fd91ee0ae77b53ff3874c7b1f1eadb6566248e880958" + sha256: "1417d8846663df5e7b77ca56591c5edd442c66ffc9c01ab036e138a21a148e86" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" graphs: dependency: transitive description: @@ -333,10 +333,10 @@ packages: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -413,10 +413,10 @@ packages: dependency: transitive description: name: meta - sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.18.2" + version: "1.18.3" mime: dependency: transitive description: @@ -517,10 +517,10 @@ packages: dependency: transitive description: name: propertylistserialization - sha256: "645aaac37e73735286087fc81b27c7eabaa81b6146881bdf84090ba56f4e15ad" + sha256: "8ebe5e73b912c080916895bff96af9dd026cf87c1867447894f84e4c9e51b4b8" url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.5.0" pub_semver: dependency: transitive description: @@ -637,18 +637,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.2.3" source_helper: dependency: transitive description: name: source_helper - sha256: "1d3b229b2934034fb2e691fbb3d53e0f75a4af7b1407f88425ed8f209bcb1b8f" + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" url: "https://pub.dev" source: hosted - version: "1.3.11" + version: "1.3.12" source_map_stack_trace: dependency: transitive description: @@ -677,10 +677,10 @@ packages: dependency: transitive description: name: space_gen - sha256: "3aea8da8ffba5802110322093ef4e921e297d9b55ffa073a74b6cb38fd225ee1" + sha256: fb609d3e1d992e8da943990d0bae08bb0b5a05a20c95b66feee1f2a30c0ad60b url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.3.0" stack_trace: dependency: transitive description: @@ -725,26 +725,26 @@ packages: dependency: transitive description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.2" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.13" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132 url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.19" ttl_cache: dependency: transitive description: @@ -781,10 +781,10 @@ packages: dependency: "direct dev" description: name: very_good_analysis - sha256: d1cb1d66a5aae2c702d68caca6c8347306d35e728fd94555fa21fa0448a972e0 + sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "10.3.0" vm_service: dependency: transitive description: @@ -845,10 +845,10 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "7.0.1" yaml: dependency: transitive description: @@ -866,4 +866,4 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.12.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 82acf171..a67f409c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,4 +19,4 @@ workspace: - packages/stripe_api dev_dependencies: - very_good_analysis: ^10.2.0 + very_good_analysis: ^10.3.0