Skip to content

Commit 7039fdc

Browse files
chore(storage): fix progress for upload, add e2e for progress, pause, resume, and cancel (#4779)
* chore: do not call `getTemporaryDirectory` on web * fix(storage): `onProgress` for upload data * chore: add platform specific e2e tests * chore: add test for pause, resume, cancel
1 parent d4a6de9 commit 7039fdc

File tree

20 files changed

+697
-77
lines changed

20 files changed

+697
-77
lines changed
Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,131 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import 'package:amplify_core/amplify_core.dart';
4+
import 'dart:async';
5+
import 'dart:convert';
6+
7+
import 'package:aws_common/aws_common.dart';
8+
import 'package:meta/meta.dart';
59

610
/// {@template amplify_core.storage.data_payload}
7-
/// A data payload to be uploaded by a plugin of the Storage category.
11+
/// A data payload to be uploaded by the Storage category.
12+
///
13+
/// Create a [StorageDataPayload] from various data types using one of the
14+
/// following constructors:
15+
/// - [StorageDataPayload.empty]
16+
/// - [StorageDataPayload.bytes]
17+
/// - [StorageDataPayload.string]
18+
/// - [StorageDataPayload.formFields]
19+
/// - [StorageDataPayload.json]
20+
/// - [StorageDataPayload.streaming]
21+
/// - [StorageDataPayload.dataUrl]
822
/// {@endtemplate}
9-
typedef StorageDataPayload = HttpPayload;
23+
24+
// The implementation is based on HttpPayload from aws_common. StorageDataPayload
25+
// converts all payloads other than streams to byte data when the object is
26+
// constructed in order to read the length of the byte data. HttpPayload does not
27+
// convert the data to byte data until th data is read. Monitoring storage
28+
// upload progress requires knowing the length of the data prior to the start
29+
// of the upload.
30+
//
31+
class StorageDataPayload extends StreamView<List<int>> {
32+
/// An empty [StorageDataPayload].
33+
const StorageDataPayload.empty({this.contentType})
34+
: size = 0,
35+
super(const Stream.empty());
36+
37+
/// A byte buffer [StorageDataPayload].
38+
StorageDataPayload.bytes(
39+
List<int> body, {
40+
this.contentType,
41+
}) : size = body.length,
42+
super(Stream.value(body));
43+
44+
/// A [StorageDataPayload].
45+
///
46+
/// Defaults to UTF-8 encoding.
47+
///
48+
/// The Content-Type defaults to 'text/plain'.
49+
factory StorageDataPayload.string(
50+
String body, {
51+
Encoding encoding = utf8,
52+
String? contentType,
53+
}) {
54+
return StorageDataPayload.bytes(
55+
encoding.encode(body),
56+
contentType: contentType ?? 'text/plain; charset=${encoding.name}',
57+
);
58+
}
59+
60+
/// A form-encoded [StorageDataPayload].
61+
///
62+
/// The Content-Type defaults to 'application/x-www-form-urlencoded'.
63+
factory StorageDataPayload.formFields(
64+
Map<String, String> body, {
65+
Encoding encoding = utf8,
66+
String? contentType,
67+
}) {
68+
return StorageDataPayload.bytes(
69+
// ignore: invalid_use_of_internal_member
70+
encoding.encode(HttpPayload.encodeFormValues(body, encoding: encoding)),
71+
contentType: contentType ??
72+
'application/x-www-form-urlencoded; charset=${encoding.name}',
73+
);
74+
}
75+
76+
/// A JSON [StorageDataPayload]
77+
///
78+
/// The Content-Type defaults to 'application/json'.
79+
factory StorageDataPayload.json(
80+
Object? body, {
81+
Encoding encoding = utf8,
82+
String? contentType,
83+
}) {
84+
return StorageDataPayload.bytes(
85+
encoding.encode(json.encode(body)),
86+
contentType: contentType ?? 'application/json; charset=${encoding.name}',
87+
);
88+
}
89+
90+
/// A streaming [StorageDataPayload].
91+
const StorageDataPayload.streaming(
92+
super.body, {
93+
this.contentType,
94+
}) : size = -1;
95+
96+
/// A data url [StorageDataPayload].
97+
factory StorageDataPayload.dataUrl(String dataUrl) {
98+
// ignore: invalid_use_of_internal_member
99+
if (!dataUrl.startsWith(HttpPayload.dataUrlMatcher)) {
100+
throw ArgumentError('Invalid data url: $dataUrl');
101+
}
102+
103+
final dataUrlParts = dataUrl.split(',');
104+
final mediaTypeEncoding = dataUrlParts.first.replaceFirst('data:', '');
105+
final body = dataUrlParts.skip(1).join(',');
106+
107+
if (mediaTypeEncoding.endsWith(';base64')) {
108+
return StorageDataPayload.bytes(
109+
base64Decode(body),
110+
contentType: mediaTypeEncoding.replaceFirst(';base64', ''),
111+
);
112+
}
113+
114+
return StorageDataPayload.bytes(
115+
// data url encodes body, need to decode before converting it into bytes
116+
utf8.encode(Uri.decodeComponent(body)),
117+
contentType: mediaTypeEncoding,
118+
);
119+
}
120+
121+
/// The content type of the body.
122+
final String? contentType;
123+
124+
/// The size of the content of the data payload.
125+
///
126+
/// If this payload was created using the [StorageDataPayload.streaming]
127+
/// constructor the size will be unknown until the stream completes. This
128+
/// value will return -1 in that case.
129+
@internal
130+
final int size;
131+
}

packages/amplify_core/lib/src/types/storage/transfer_progress.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class StorageTransferProgress {
4646

4747
/// The fractional progress of the storage transfer operation.
4848
///
49-
/// 0 <= `fractionCompleted` <= 1
50-
double get fractionCompleted => transferredBytes / totalBytes;
49+
/// fractionCompleted will be between 0 and 1, unless the upload source size
50+
/// cannot be determined in which case the fractionCompleted will be -1.
51+
double get fractionCompleted =>
52+
totalBytes == -1 ? -1 : transferredBytes / totalBytes;
5153
}

packages/aws_common/lib/src/http/http_payload.dart

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'dart:async';
55
import 'dart:convert';
66

77
import 'package:async/async.dart';
8+
import 'package:meta/meta.dart';
89

910
/// {@template aws_common.http.http_payload}
1011
/// An HTTP request's payload.
@@ -61,7 +62,7 @@ final class HttpPayload extends StreamView<List<int>> {
6162
super(
6263
LazyStream(
6364
() => Stream.value(
64-
encoding.encode(_encodeFormValues(body, encoding: encoding)),
65+
encoding.encode(encodeFormValues(body, encoding: encoding)),
6566
),
6667
),
6768
);
@@ -87,7 +88,7 @@ final class HttpPayload extends StreamView<List<int>> {
8788

8889
/// A data url HTTP body.
8990
factory HttpPayload.dataUrl(String dataUrl) {
90-
if (!dataUrl.startsWith(_dataUrlMatcher)) {
91+
if (!dataUrl.startsWith(dataUrlMatcher)) {
9192
throw ArgumentError('Invalid data url: $dataUrl');
9293
}
9394

@@ -118,7 +119,8 @@ final class HttpPayload extends StreamView<List<int>> {
118119
/// //=> "foo=bar&baz=bang"
119120
///
120121
/// Similar util at https://github.com/dart-lang/http/blob/06649afbb5847dbb0293816ba8348766b116e419/pkgs/http/lib/src/utils.dart#L15
121-
static String _encodeFormValues(
122+
@internal
123+
static String encodeFormValues(
122124
Map<String, String> params, {
123125
required Encoding encoding,
124126
}) =>
@@ -131,5 +133,7 @@ final class HttpPayload extends StreamView<List<int>> {
131133
)
132134
.join('&');
133135

134-
static final _dataUrlMatcher = RegExp(r'^data:.*,');
136+
/// A [RegExp] matcher for data urls.
137+
@internal
138+
static final dataUrlMatcher = RegExp(r'^data:.*,');
135139
}

packages/storage/amplify_storage_s3/example/integration_test/copy_test.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ void main() {
2424
await configure(amplifyEnvironments['main']!);
2525
addTearDownPath(srcStoragePath);
2626
await Amplify.Storage.uploadData(
27-
data: HttpPayload.bytes('data'.codeUnits),
27+
data: StorageDataPayload.bytes('data'.codeUnits),
2828
path: srcStoragePath,
2929
options: const StorageUploadDataOptions(metadata: metadata),
3030
).result;
@@ -50,7 +50,7 @@ void main() {
5050
final identityId = await signInNewUser();
5151
addTearDownPath(srcStoragePath);
5252
await Amplify.Storage.uploadData(
53-
data: HttpPayload.bytes('data'.codeUnits),
53+
data: StorageDataPayload.bytes('data'.codeUnits),
5454
path: srcStoragePath,
5555
).result;
5656
final destinationFileName = 'copy-source-${uuid()}';
@@ -120,7 +120,7 @@ void main() {
120120
await configure(amplifyEnvironments['dots-in-name']!);
121121
addTearDownPath(srcStoragePath);
122122
await Amplify.Storage.uploadData(
123-
data: HttpPayload.bytes('data'.codeUnits),
123+
data: StorageDataPayload.bytes('data'.codeUnits),
124124
path: srcStoragePath,
125125
options: const StorageUploadDataOptions(metadata: metadata),
126126
).result;

packages/storage/amplify_storage_s3/example/integration_test/download_data_test.dart

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import 'dart:async';
45
import 'dart:convert';
56

67
import 'package:amplify_core/amplify_core.dart';
@@ -31,19 +32,19 @@ void main() {
3132

3233
await Amplify.Storage.uploadData(
3334
path: StoragePath.fromString(publicPath),
34-
data: HttpPayload.bytes(bytesData),
35+
data: StorageDataPayload.bytes(bytesData),
3536
).result;
3637

3738
await Amplify.Storage.uploadData(
38-
data: HttpPayload.bytes(identityData),
39+
data: StorageDataPayload.bytes(identityData),
3940
path: StoragePath.fromIdentityId(
4041
(identityId) => 'private/$identityId/$identityName',
4142
),
4243
).result;
4344

4445
await Amplify.Storage.uploadData(
4546
path: StoragePath.fromString(metadataPath),
46-
data: HttpPayload.bytes('get properties'.codeUnits),
47+
data: StorageDataPayload.bytes('get properties'.codeUnits),
4748
options: StorageUploadDataOptions(
4849
pluginOptions: const S3UploadDataPluginOptions(
4950
getProperties: true,
@@ -132,6 +133,75 @@ void main() {
132133
expect(downloadResult.downloadedItem.path, publicPath);
133134
});
134135
});
136+
137+
group('download progress', () {
138+
testWidgets('reports progress', (_) async {
139+
var fractionCompleted = 0.0;
140+
var totalBytes = 0;
141+
var transferredBytes = 0;
142+
143+
await Amplify.Storage.downloadData(
144+
path: StoragePath.fromString(publicPath),
145+
onProgress: (StorageTransferProgress progress) {
146+
fractionCompleted = progress.fractionCompleted;
147+
totalBytes = progress.totalBytes;
148+
transferredBytes = progress.transferredBytes;
149+
},
150+
).result;
151+
expect(fractionCompleted, 1.0);
152+
expect(totalBytes, bytesData.length);
153+
expect(transferredBytes, bytesData.length);
154+
});
155+
});
156+
157+
group('pause, resume, cancel', () {
158+
const size = 1024 * 1024 * 6;
159+
const chars = 'qwertyuiopasdfghjklzxcvbnm';
160+
final content = List.generate(size, (i) => chars[i % 25]).join();
161+
final fileId = uuid();
162+
final path = 'public/download-data-pause-$fileId';
163+
setUpAll(() async {
164+
addTearDownPath(StoragePath.fromString(path));
165+
await Amplify.Storage.uploadData(
166+
data: StorageDataPayload.string(content),
167+
path: StoragePath.fromString(path),
168+
).result;
169+
});
170+
testWidgets('can pause', (_) async {
171+
final operation = Amplify.Storage.downloadData(
172+
path: StoragePath.fromString(path),
173+
);
174+
await operation.pause();
175+
unawaited(
176+
operation.result.then(
177+
(value) => fail('should not complete after pause'),
178+
),
179+
);
180+
await Future<void>.delayed(const Duration(seconds: 15));
181+
});
182+
183+
testWidgets('can resume', (_) async {
184+
final operation = Amplify.Storage.downloadData(
185+
path: StoragePath.fromString(path),
186+
);
187+
await operation.pause();
188+
await operation.resume();
189+
final result = await operation.result;
190+
expect(result.downloadedItem.path, path);
191+
});
192+
193+
testWidgets('can cancel', (_) async {
194+
final operation = Amplify.Storage.downloadData(
195+
path: StoragePath.fromString(path),
196+
);
197+
final expectException = expectLater(
198+
() => operation.result,
199+
throwsA(isA<StorageOperationCanceledException>()),
200+
);
201+
await operation.cancel();
202+
await expectException;
203+
});
204+
});
135205
});
136206

137207
group('config with dots in name', () {
@@ -140,7 +210,7 @@ void main() {
140210
addTearDownPath(StoragePath.fromString(publicPath));
141211
await Amplify.Storage.uploadData(
142212
path: StoragePath.fromString(publicPath),
143-
data: HttpPayload.bytes(bytesData),
213+
data: StorageDataPayload.bytes(bytesData),
144214
).result;
145215
});
146216
testWidgets(

0 commit comments

Comments
 (0)