Compare commits
32 Commits
07a654e771
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 41754f43ae | |||
| 05ccef7e81 | |||
| 81b6c2550e | |||
| 023c9ad3a0 | |||
| 60edcfdcb2 | |||
| e2873ea37e | |||
| 721ee33405 | |||
| ab5e65e9f8 | |||
| d6647dbf65 | |||
| b0149caf5b | |||
| 24057198ad | |||
| 38fd88c5e9 | |||
| f098f2587b | |||
| 9086e89bd5 | |||
| 4f43c06456 | |||
| 7b68a8d257 | |||
| 82a0c99c2b | |||
| ffe24a5fc1 | |||
| 1b9b9bf201 | |||
| 9570be87ba | |||
| db3a6af43b | |||
| b8379117e0 | |||
| 0accf66986 | |||
| a8456860ec | |||
| cdd8cefb27 | |||
| 12ade01071 | |||
| 53e4576680 | |||
| 9beefe8370 | |||
| 851f1148f3 | |||
| e7aa48009b | |||
| c9e4c21b62 | |||
| 573052c55e |
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📚 Git Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📚 Git Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📚 Git Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📚 Git Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: 🐳 Run Redis
|
||||
if: inputs.needs_redis
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -19,8 +19,8 @@ The combined workspace provides these open replacements:
|
||||
| Artifact mirror/proxy | `packages/artifact_proxy` |
|
||||
| Runtime updater library | `../updater/library`, linked into the Flutter engine by `../scripts/sync_open_sources.sh` |
|
||||
| Patch artifact generator | `../updater/patch` and `packages/open_aot_patch_tools` |
|
||||
| Flutter engine and Flutter tool defaults | `../flutter`, linked to `../dart-sdk-new` and `../updater` |
|
||||
| Dart VM patch runtime | `../dart-sdk-new` |
|
||||
| Flutter engine and Flutter tool defaults | `../flutter`, linked to `../dart-sdk` and `../updater` |
|
||||
| Dart VM patch runtime | `../dart-sdk` |
|
||||
|
||||
Run source linking from the workspace root:
|
||||
|
||||
@@ -31,7 +31,7 @@ Run source linking from the workspace root:
|
||||
The script links:
|
||||
|
||||
```text
|
||||
flutter/engine/src/flutter/third_party/dart -> dart-sdk-new
|
||||
flutter/engine/src/flutter/third_party/dart -> dart-sdk
|
||||
flutter/engine/src/flutter/third_party/updater -> updater
|
||||
```
|
||||
|
||||
|
||||
+60
-1
@@ -1,9 +1,68 @@
|
||||
# Release Notes
|
||||
|
||||
<!--
|
||||
cspell:words pubspec erickzanardo xcframeworks cupertino codesign codecov rkishan appbundle proto tlsv kingdomseed Peetee Aditya serde bipatch
|
||||
cspell:words pubspec erickzanardo xcframeworks cupertino codesign codecov rkishan appbundle proto tlsv kingdomseed Peetee Aditya serde bipatch GLES lipo
|
||||
-->
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1 +1 @@
|
||||
65b1e4368e98a85ea11e8ef2cffc286c3b2ad8b2
|
||||
c15ef6379403a0a55531a058bdb2c8e55bc05c98
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ $flutterPath = [IO.Path]::Combine($shorebirdCacheDir, "flutter", $flutterVersion
|
||||
$flutter = [IO.Path]::Combine($shorebirdCacheDir, "flutter", $flutterVersion, "bin", "flutter.bat")
|
||||
$shorebirdScript = [IO.Path]::Combine($shorebirdCliDir, "bin", "shorebird.dart")
|
||||
$dart = [IO.Path]::Combine($flutterPath, "bin", "cache", "dart-sdk", "bin", "dart.exe")
|
||||
$defaultFlutterGitUrl = "https://git.tonycloud.org/flutter/flutter.git"
|
||||
$defaultFlutterGitUrl = "https://github.com/tony-cloud/flutter.git"
|
||||
$defaultFlutterStorageBaseUrl = "http://localhost:8080/download.flutter.io"
|
||||
|
||||
# Executes $command and redirects as much output to $null as possible.
|
||||
|
||||
@@ -4,7 +4,7 @@ This workspace builds against the open Flutter fork configured in
|
||||
`SHOREBIRD_FLUTTER_GIT_URL`, which defaults to:
|
||||
|
||||
```text
|
||||
https://git.tonycloud.org/flutter/flutter.git
|
||||
https://github.com/tony-cloud/flutter.git
|
||||
```
|
||||
|
||||
Use `shorebird flutter versions list` to inspect versions known to the local
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,4 +15,4 @@ dependencies:
|
||||
|
||||
dev_dependencies:
|
||||
mocktail: ^1.0.5
|
||||
test: ^1.31.1
|
||||
test: ^1.31.2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,7 +101,7 @@ and writes the file that the engine patch cache can map before isolate startup.
|
||||
The host proof app is `testapps/license_flavor_patch_test`. Run:
|
||||
|
||||
```powershell
|
||||
dart-sdk-new\tools\sdks\dart-sdk\bin\dart.exe testapps\license_flavor_patch_test\tool\verify_aot_patch.dart
|
||||
dart-sdk\tools\sdks\dart-sdk\bin\dart.exe testapps\license_flavor_patch_test\tool\verify_aot_patch.dart
|
||||
```
|
||||
|
||||
It compiles a `free` base AOT snapshot and a `pro` patch snapshot, reuses the
|
||||
|
||||
@@ -13,4 +13,4 @@ dependencies:
|
||||
resp_client: ^1.2.0
|
||||
|
||||
dev_dependencies:
|
||||
test: ^1.31.1
|
||||
test: ^1.31.2
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
<!-- cspell:words Angelov mrgnhnt -->
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool> _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');
|
||||
|
||||
@@ -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://git.tonycloud.org/flutter/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;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class ShorebirdFlutter {
|
||||
|
||||
/// The Shorebird Flutter fork git URL.
|
||||
static const String defaultFlutterGitUrl =
|
||||
'https://git.tonycloud.org/flutter/flutter.git';
|
||||
'https://github.com/tony-cloud/flutter.git';
|
||||
|
||||
/// The Flutter fork git URL used when installing vended Flutter revisions.
|
||||
String get flutterGitUrl {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Generated code. Do not modify.
|
||||
const packageVersion = '1.6.108';
|
||||
const packageVersion = '1.6.115';
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String, String>? 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 = <String, String>{'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<String, dynamic>,
|
||||
);
|
||||
|
||||
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<String, dynamic>,
|
||||
);
|
||||
|
||||
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<void> _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<void> _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<void>();
|
||||
|
||||
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<void> _backoff(int failures) =>
|
||||
Future<void>.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<int> _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<void>();
|
||||
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].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<http.BaseRequest>();
|
||||
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<http.BaseRequest>();
|
||||
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<Future<http.StreamedResponse> Function()> actions,
|
||||
) {
|
||||
when(
|
||||
() => httpClient.send(any()),
|
||||
).thenAnswer((_) => actions.removeAt(0)());
|
||||
}
|
||||
|
||||
Future<void> upload(String artifactPath) =>
|
||||
codePushClient.createReleaseArtifact(
|
||||
appId: appId,
|
||||
artifactPath: artifactPath,
|
||||
releaseId: releaseId,
|
||||
arch: arch,
|
||||
platform: platform,
|
||||
hash: hash,
|
||||
canSideload: canSideload,
|
||||
podfileLockHash: null,
|
||||
);
|
||||
|
||||
List<http.BaseRequest> capturedPuts() =>
|
||||
verify(
|
||||
() => httpClient.send(captureAny()),
|
||||
).captured
|
||||
.cast<http.BaseRequest>()
|
||||
.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<CodePushException>().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<CodePushException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<CodePushException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<CodePushException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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 = <Future<http.StreamedResponse> 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<CodePushException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('createApp', () {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+17
-2
@@ -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<String, dynamic>` 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<String, dynamic>`.
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -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<String, dynamic>` 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<String, dynamic>`.
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+100
@@ -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<String, dynamic>` to a [GetActivityHeatmapResponse].
|
||||
factory GetActivityHeatmapResponse.fromJson(Map<String, dynamic> json) {
|
||||
return parseFromJson(
|
||||
'GetActivityHeatmapResponse',
|
||||
json,
|
||||
() => GetActivityHeatmapResponse(
|
||||
cells: (json['cells'] as List)
|
||||
.map<ActivityHeatmapCell>(
|
||||
(e) => ActivityHeatmapCell.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.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<String, dynamic>? 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<ActivityHeatmapCell> 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<String, dynamic>`.
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
+88
@@ -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<String, dynamic>` to a [GetNewDevicesResponse].
|
||||
factory GetNewDevicesResponse.fromJson(Map<String, dynamic> 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<String, dynamic>? 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<String, dynamic>`.
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
+104
@@ -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<String, dynamic>` to a [GetPatchMetricResponse].
|
||||
factory GetPatchMetricResponse.fromJson(Map<String, dynamic> 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<String, dynamic>,
|
||||
),
|
||||
previous: PatchMetricWindow.maybeFromJson(
|
||||
json['previous'] as Map<String, dynamic>?,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience to create a nullable type from a nullable json object.
|
||||
/// Useful when parsing optional fields.
|
||||
static GetPatchMetricResponse? maybeFromJson(Map<String, dynamic>? 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<String, dynamic>`.
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
-8
@@ -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 &&
|
||||
|
||||
@@ -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<String, dynamic>` to an [ActivityHeatmapCell].
|
||||
factory ActivityHeatmapCell.fromJson(Map<String, dynamic> 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<String, dynamic>? 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<String, dynamic>`.
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
enum GetUniqueUsersParameter3 {
|
||||
platform._('platform');
|
||||
platform._('platform'),
|
||||
releaseVersion._('release_version');
|
||||
|
||||
const GetUniqueUsersParameter3._(this.value);
|
||||
|
||||
|
||||
+90
@@ -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<String, dynamic>` to a [PatchMetricBreakdownEntry].
|
||||
factory PatchMetricBreakdownEntry.fromJson(Map<String, dynamic> 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<PatchMetricTimeSeriesEntry>(
|
||||
(e) => PatchMetricTimeSeriesEntry.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience to create a nullable type from a nullable json object.
|
||||
/// Useful when parsing optional fields.
|
||||
static PatchMetricBreakdownEntry? maybeFromJson(Map<String, dynamic>? 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<PatchMetricTimeSeriesEntry>? timeSeries;
|
||||
|
||||
/// Converts a [PatchMetricBreakdownEntry] to a `Map<String, dynamic>`.
|
||||
Map<String, dynamic> 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);
|
||||
}
|
||||
}
|
||||
+108
@@ -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<String, dynamic>` to a [PatchMetricCurrentWindow].
|
||||
factory PatchMetricCurrentWindow.fromJson(Map<String, dynamic> json) {
|
||||
return parseFromJson(
|
||||
'PatchMetricCurrentWindow',
|
||||
json,
|
||||
() => PatchMetricCurrentWindow(
|
||||
count: json['count'] as int,
|
||||
range: MetricsRange.fromJson(json['range'] as Map<String, dynamic>),
|
||||
timeSeries: (json['time_series'] as List?)
|
||||
?.map<PatchMetricTimeSeriesEntry>(
|
||||
(e) => PatchMetricTimeSeriesEntry.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
breakdown: (json['breakdown'] as List?)
|
||||
?.map<PatchMetricBreakdownEntry>(
|
||||
(e) =>
|
||||
PatchMetricBreakdownEntry.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience to create a nullable type from a nullable json object.
|
||||
/// Useful when parsing optional fields.
|
||||
static PatchMetricCurrentWindow? maybeFromJson(Map<String, dynamic>? 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<PatchMetricTimeSeriesEntry>? timeSeries;
|
||||
|
||||
/// Per-group counts for this window, present only when a
|
||||
/// `group_by` was requested; otherwise null.
|
||||
final List<PatchMetricBreakdownEntry>? breakdown;
|
||||
|
||||
/// Converts a [PatchMetricCurrentWindow] to a `Map<String, dynamic>`.
|
||||
Map<String, dynamic> 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);
|
||||
}
|
||||
}
|
||||
+64
@@ -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<String, dynamic>` to a [PatchMetricTimeSeriesEntry].
|
||||
factory PatchMetricTimeSeriesEntry.fromJson(Map<String, dynamic> 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<String, dynamic>? 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<String, dynamic>`.
|
||||
Map<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic>` to a [PatchMetricWindow].
|
||||
factory PatchMetricWindow.fromJson(Map<String, dynamic> json) {
|
||||
return parseFromJson(
|
||||
'PatchMetricWindow',
|
||||
json,
|
||||
() => PatchMetricWindow(
|
||||
count: json['count'] as int,
|
||||
range: MetricsRange.fromJson(json['range'] as Map<String, dynamic>),
|
||||
timeSeries: (json['time_series'] as List?)
|
||||
?.map<PatchMetricTimeSeriesEntry>(
|
||||
(e) => PatchMetricTimeSeriesEntry.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience to create a nullable type from a nullable json object.
|
||||
/// Useful when parsing optional fields.
|
||||
static PatchMetricWindow? maybeFromJson(Map<String, dynamic>? 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<PatchMetricTimeSeriesEntry>? timeSeries;
|
||||
|
||||
/// Converts a [PatchMetricWindow] to a `Map<String, dynamic>`.
|
||||
Map<String, dynamic> 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);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
@@ -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>[
|
||||
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(<String, dynamic>{}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+30
@@ -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(<String, dynamic>{}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+35
@@ -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(<String, dynamic>{}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+29
@@ -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(<String, dynamic>{}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+44
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+44
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+44
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+44
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+44
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+44
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+44
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+44
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+31
@@ -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(<String, dynamic>{}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+31
@@ -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(<String, dynamic>{}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+30
@@ -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(<String, dynamic>{}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+31
@@ -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(<String, dynamic>{}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+16
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+16
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
-3
@@ -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',
|
||||
|
||||
+28
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String, dynamic>) {
|
||||
final error = decoded['error'];
|
||||
if (error is Map<String, dynamic> && 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';
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'src/models/models.dart';
|
||||
export 'src/stripe_api.dart';
|
||||
export 'src/stripe_api_exception.dart';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<StripeApiException>()
|
||||
.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<StripeApiException>()
|
||||
.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('<html>502 Bad Gateway</html>', 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+35
-35
@@ -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"
|
||||
|
||||
+1
-1
@@ -19,4 +19,4 @@ workspace:
|
||||
- packages/stripe_api
|
||||
|
||||
dev_dependencies:
|
||||
very_good_analysis: ^10.2.0
|
||||
very_good_analysis: ^10.3.0
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ unset CDPATH
|
||||
# Either clones or pulls the configured Flutter repository, depending on
|
||||
# whether FLUTTER_PATH exists.
|
||||
function update_flutter {
|
||||
local flutter_git_url="${SHOREBIRD_FLUTTER_GIT_URL:-https://git.tonycloud.org/flutter/flutter.git}"
|
||||
local flutter_git_url="${SHOREBIRD_FLUTTER_GIT_URL:-https://github.com/tony-cloud/flutter.git}"
|
||||
local flutter_storage_base_url="${SHOREBIRD_FLUTTER_STORAGE_BASE_URL:-${FLUTTER_STORAGE_BASE_URL:-http://localhost:8080/download.flutter.io}}"
|
||||
|
||||
if [[ -d "$FLUTTER_PATH" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user