Skip to content

Commit

Permalink
chore(storage): fix progress for upload, add e2e for progress, pause,…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
Jordan-Nelson authored Apr 26, 2024
1 parent d4a6de9 commit 7039fdc
Show file tree
Hide file tree
Showing 20 changed files with 697 additions and 77 deletions.
128 changes: 125 additions & 3 deletions packages/amplify_core/lib/src/types/storage/data_payload.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,131 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import 'package:amplify_core/amplify_core.dart';
import 'dart:async';
import 'dart:convert';

import 'package:aws_common/aws_common.dart';
import 'package:meta/meta.dart';

/// {@template amplify_core.storage.data_payload}
/// A data payload to be uploaded by a plugin of the Storage category.
/// A data payload to be uploaded by the Storage category.
///
/// Create a [StorageDataPayload] from various data types using one of the
/// following constructors:
/// - [StorageDataPayload.empty]
/// - [StorageDataPayload.bytes]
/// - [StorageDataPayload.string]
/// - [StorageDataPayload.formFields]
/// - [StorageDataPayload.json]
/// - [StorageDataPayload.streaming]
/// - [StorageDataPayload.dataUrl]
/// {@endtemplate}
typedef StorageDataPayload = HttpPayload;
// The implementation is based on HttpPayload from aws_common. StorageDataPayload
// converts all payloads other than streams to byte data when the object is
// constructed in order to read the length of the byte data. HttpPayload does not
// convert the data to byte data until th data is read. Monitoring storage
// upload progress requires knowing the length of the data prior to the start
// of the upload.
//
class StorageDataPayload extends StreamView<List<int>> {
/// An empty [StorageDataPayload].
const StorageDataPayload.empty({this.contentType})
: size = 0,
super(const Stream.empty());

/// A byte buffer [StorageDataPayload].
StorageDataPayload.bytes(
List<int> body, {
this.contentType,
}) : size = body.length,
super(Stream.value(body));

/// A [StorageDataPayload].
///
/// Defaults to UTF-8 encoding.
///
/// The Content-Type defaults to 'text/plain'.
factory StorageDataPayload.string(
String body, {
Encoding encoding = utf8,
String? contentType,
}) {
return StorageDataPayload.bytes(
encoding.encode(body),
contentType: contentType ?? 'text/plain; charset=${encoding.name}',
);
}

/// A form-encoded [StorageDataPayload].
///
/// The Content-Type defaults to 'application/x-www-form-urlencoded'.
factory StorageDataPayload.formFields(
Map<String, String> body, {
Encoding encoding = utf8,
String? contentType,
}) {
return StorageDataPayload.bytes(
// ignore: invalid_use_of_internal_member
encoding.encode(HttpPayload.encodeFormValues(body, encoding: encoding)),
contentType: contentType ??
'application/x-www-form-urlencoded; charset=${encoding.name}',
);
}

/// A JSON [StorageDataPayload]
///
/// The Content-Type defaults to 'application/json'.
factory StorageDataPayload.json(
Object? body, {
Encoding encoding = utf8,
String? contentType,
}) {
return StorageDataPayload.bytes(
encoding.encode(json.encode(body)),
contentType: contentType ?? 'application/json; charset=${encoding.name}',
);
}

/// A streaming [StorageDataPayload].
const StorageDataPayload.streaming(
super.body, {
this.contentType,
}) : size = -1;

/// A data url [StorageDataPayload].
factory StorageDataPayload.dataUrl(String dataUrl) {
// ignore: invalid_use_of_internal_member
if (!dataUrl.startsWith(HttpPayload.dataUrlMatcher)) {
throw ArgumentError('Invalid data url: $dataUrl');
}

final dataUrlParts = dataUrl.split(',');
final mediaTypeEncoding = dataUrlParts.first.replaceFirst('data:', '');
final body = dataUrlParts.skip(1).join(',');

if (mediaTypeEncoding.endsWith(';base64')) {
return StorageDataPayload.bytes(
base64Decode(body),
contentType: mediaTypeEncoding.replaceFirst(';base64', ''),
);
}

return StorageDataPayload.bytes(
// data url encodes body, need to decode before converting it into bytes
utf8.encode(Uri.decodeComponent(body)),
contentType: mediaTypeEncoding,
);
}

/// The content type of the body.
final String? contentType;

/// The size of the content of the data payload.
///
/// If this payload was created using the [StorageDataPayload.streaming]
/// constructor the size will be unknown until the stream completes. This
/// value will return -1 in that case.
@internal
final int size;
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class StorageTransferProgress {

/// The fractional progress of the storage transfer operation.
///
/// 0 <= `fractionCompleted` <= 1
double get fractionCompleted => transferredBytes / totalBytes;
/// fractionCompleted will be between 0 and 1, unless the upload source size
/// cannot be determined in which case the fractionCompleted will be -1.
double get fractionCompleted =>
totalBytes == -1 ? -1 : transferredBytes / totalBytes;
}
12 changes: 8 additions & 4 deletions packages/aws_common/lib/src/http/http_payload.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'dart:async';
import 'dart:convert';

import 'package:async/async.dart';
import 'package:meta/meta.dart';

/// {@template aws_common.http.http_payload}
/// An HTTP request's payload.
Expand Down Expand Up @@ -61,7 +62,7 @@ final class HttpPayload extends StreamView<List<int>> {
super(
LazyStream(
() => Stream.value(
encoding.encode(_encodeFormValues(body, encoding: encoding)),
encoding.encode(encodeFormValues(body, encoding: encoding)),
),
),
);
Expand All @@ -87,7 +88,7 @@ final class HttpPayload extends StreamView<List<int>> {

/// A data url HTTP body.
factory HttpPayload.dataUrl(String dataUrl) {
if (!dataUrl.startsWith(_dataUrlMatcher)) {
if (!dataUrl.startsWith(dataUrlMatcher)) {
throw ArgumentError('Invalid data url: $dataUrl');
}

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

static final _dataUrlMatcher = RegExp(r'^data:.*,');
/// A [RegExp] matcher for data urls.
@internal
static final dataUrlMatcher = RegExp(r'^data:.*,');
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ void main() {
await configure(amplifyEnvironments['main']!);
addTearDownPath(srcStoragePath);
await Amplify.Storage.uploadData(
data: HttpPayload.bytes('data'.codeUnits),
data: StorageDataPayload.bytes('data'.codeUnits),
path: srcStoragePath,
options: const StorageUploadDataOptions(metadata: metadata),
).result;
Expand All @@ -50,7 +50,7 @@ void main() {
final identityId = await signInNewUser();
addTearDownPath(srcStoragePath);
await Amplify.Storage.uploadData(
data: HttpPayload.bytes('data'.codeUnits),
data: StorageDataPayload.bytes('data'.codeUnits),
path: srcStoragePath,
).result;
final destinationFileName = 'copy-source-${uuid()}';
Expand Down Expand Up @@ -120,7 +120,7 @@ void main() {
await configure(amplifyEnvironments['dots-in-name']!);
addTearDownPath(srcStoragePath);
await Amplify.Storage.uploadData(
data: HttpPayload.bytes('data'.codeUnits),
data: StorageDataPayload.bytes('data'.codeUnits),
path: srcStoragePath,
options: const StorageUploadDataOptions(metadata: metadata),
).result;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import 'dart:async';
import 'dart:convert';

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

await Amplify.Storage.uploadData(
path: StoragePath.fromString(publicPath),
data: HttpPayload.bytes(bytesData),
data: StorageDataPayload.bytes(bytesData),
).result;

await Amplify.Storage.uploadData(
data: HttpPayload.bytes(identityData),
data: StorageDataPayload.bytes(identityData),
path: StoragePath.fromIdentityId(
(identityId) => 'private/$identityId/$identityName',
),
).result;

await Amplify.Storage.uploadData(
path: StoragePath.fromString(metadataPath),
data: HttpPayload.bytes('get properties'.codeUnits),
data: StorageDataPayload.bytes('get properties'.codeUnits),
options: StorageUploadDataOptions(
pluginOptions: const S3UploadDataPluginOptions(
getProperties: true,
Expand Down Expand Up @@ -132,6 +133,75 @@ void main() {
expect(downloadResult.downloadedItem.path, publicPath);
});
});

group('download progress', () {
testWidgets('reports progress', (_) async {
var fractionCompleted = 0.0;
var totalBytes = 0;
var transferredBytes = 0;

await Amplify.Storage.downloadData(
path: StoragePath.fromString(publicPath),
onProgress: (StorageTransferProgress progress) {
fractionCompleted = progress.fractionCompleted;
totalBytes = progress.totalBytes;
transferredBytes = progress.transferredBytes;
},
).result;
expect(fractionCompleted, 1.0);
expect(totalBytes, bytesData.length);
expect(transferredBytes, bytesData.length);
});
});

group('pause, resume, cancel', () {
const size = 1024 * 1024 * 6;
const chars = 'qwertyuiopasdfghjklzxcvbnm';
final content = List.generate(size, (i) => chars[i % 25]).join();
final fileId = uuid();
final path = 'public/download-data-pause-$fileId';
setUpAll(() async {
addTearDownPath(StoragePath.fromString(path));
await Amplify.Storage.uploadData(
data: StorageDataPayload.string(content),
path: StoragePath.fromString(path),
).result;
});
testWidgets('can pause', (_) async {
final operation = Amplify.Storage.downloadData(
path: StoragePath.fromString(path),
);
await operation.pause();
unawaited(
operation.result.then(
(value) => fail('should not complete after pause'),
),
);
await Future<void>.delayed(const Duration(seconds: 15));
});

testWidgets('can resume', (_) async {
final operation = Amplify.Storage.downloadData(
path: StoragePath.fromString(path),
);
await operation.pause();
await operation.resume();
final result = await operation.result;
expect(result.downloadedItem.path, path);
});

testWidgets('can cancel', (_) async {
final operation = Amplify.Storage.downloadData(
path: StoragePath.fromString(path),
);
final expectException = expectLater(
() => operation.result,
throwsA(isA<StorageOperationCanceledException>()),
);
await operation.cancel();
await expectException;
});
});
});

group('config with dots in name', () {
Expand All @@ -140,7 +210,7 @@ void main() {
addTearDownPath(StoragePath.fromString(publicPath));
await Amplify.Storage.uploadData(
path: StoragePath.fromString(publicPath),
data: HttpPayload.bytes(bytesData),
data: StorageDataPayload.bytes(bytesData),
).result;
});
testWidgets(
Expand Down
Loading

0 comments on commit 7039fdc

Please sign in to comment.