fix: out of memory error when uploading large assets on slow internet (#224)

This commit is contained in:
Zack Pollard 2022-06-18 13:36:58 +01:00 committed by GitHub
parent 360c1d9a15
commit e6efc61b3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 97 additions and 56 deletions

View File

@ -23,6 +23,8 @@ PODS:
- Flutter - Flutter
- FMDB (>= 2.7.5) - FMDB (>= 2.7.5)
- Toast (4.0.0) - Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1): - video_player_avfoundation (0.0.1):
- Flutter - Flutter
- wakelock (0.0.1): - wakelock (0.0.1):
@ -37,6 +39,7 @@ DEPENDENCIES:
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`) - wakelock (from `.symlinks/plugins/wakelock/ios`)
@ -63,6 +66,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/photo_manager/ios" :path: ".symlinks/plugins/photo_manager/ios"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/ios" :path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation: video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios" :path: ".symlinks/plugins/video_player_avfoundation/ios"
wakelock: wakelock:
@ -80,6 +85,7 @@ SPEC CHECKSUMS:
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f

View File

@ -1,4 +1,4 @@
import 'package:dio/dio.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
@ -12,7 +12,7 @@ class BackUpState extends Equatable {
final BackUpProgressEnum backupProgress; final BackUpProgressEnum backupProgress;
final List<String> allAssetOnDatabase; final List<String> allAssetOnDatabase;
final double progressInPercentage; final double progressInPercentage;
final CancelToken cancelToken; final CancellationToken cancelToken;
final ServerInfo serverInfo; final ServerInfo serverInfo;
/// All available albums on the device /// All available albums on the device
@ -43,7 +43,7 @@ class BackUpState extends Equatable {
BackUpProgressEnum? backupProgress, BackUpProgressEnum? backupProgress,
List<String>? allAssetOnDatabase, List<String>? allAssetOnDatabase,
double? progressInPercentage, double? progressInPercentage,
CancelToken? cancelToken, CancellationToken? cancelToken,
ServerInfo? serverInfo, ServerInfo? serverInfo,
List<AvailableAlbum>? availableAlbums, List<AvailableAlbum>? availableAlbums,
Set<AssetPathEntity>? selectedBackupAlbums, Set<AssetPathEntity>? selectedBackupAlbums,

View File

@ -1,3 +1,4 @@
import 'package:cancellation_token_http/http.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
@ -19,7 +20,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
allAssetOnDatabase: const [], allAssetOnDatabase: const [],
progressInPercentage: 0, progressInPercentage: 0,
cancelToken: CancelToken(), cancelToken: CancellationToken(),
serverInfo: ServerInfo( serverInfo: ServerInfo(
diskAvailable: "0", diskAvailable: "0",
diskAvailableRaw: 0, diskAvailableRaw: 0,
@ -266,7 +267,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
// Perform Backup // Perform Backup
state = state.copyWith(cancelToken: CancelToken()); state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress); _backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress);
} else { } else {
PhotoManager.openSetting(); PhotoManager.openSetting();
@ -274,7 +275,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
void cancelBackup() { void cancelBackup() {
state.cancelToken.cancel('Cancel Backup'); state.cancelToken.cancel();
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0); state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
} }

View File

@ -8,11 +8,11 @@ import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart'; import 'package:immich_mobile/shared/models/device_info.model.dart';
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
import 'package:immich_mobile/utils/files_helper.dart'; import 'package:immich_mobile/utils/files_helper.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http;
class BackupService { class BackupService {
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
@ -26,17 +26,13 @@ class BackupService {
return result.cast<String>(); return result.cast<String>();
} }
backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, backupAsset(Set<AssetEntity> assetList, http.CancellationToken cancelToken,
Function(int, int) uploadProgress) async { Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgress) async {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file; File? file;
MultipartFile assetRawUploadData; http.MultipartFile? thumbnailUploadData;
MultipartFile thumbnailUploadData;
for (var entity in assetList) { for (var entity in assetList) {
try { try {
@ -47,35 +43,27 @@ class BackupService {
} }
if (file != null) { if (file != null) {
FormData formData;
String originalFileName = await entity.titleAsync; String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0]; String fileNameWithoutPath = originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path); var fileExtension = p.extension(file.path);
var mimeType = FileHelper.getMimeType(file.path); var mimeType = FileHelper.getMimeType(file.path);
assetRawUploadData = await MultipartFile.fromFile( var fileStream = file.openRead();
file.path, var assetRawUploadData = http.MultipartFile(
"assetData",
fileStream,
file.lengthSync(),
filename: fileNameWithoutPath, filename: fileNameWithoutPath,
contentType: MediaType( contentType: MediaType(
mimeType["type"], mimeType["type"],
mimeType["subType"], mimeType["subType"],
), ),
); );
formData = FormData.fromMap({
'deviceAssetId': entity.id,
'deviceId': deviceId,
'assetType': _getAssetType(entity.type),
'createdAt': entity.createDateTime.toIso8601String(),
'modifiedAt': entity.modifiedDateTime.toIso8601String(),
'isFavorite': entity.isFavorite,
'fileExtension': fileExtension,
'duration': entity.videoDuration,
'assetData': [assetRawUploadData]
});
// Build thumbnail multipart data // Build thumbnail multipart data
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560)); var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
if (thumbnailData != null) { if (thumbnailData != null) {
thumbnailUploadData = MultipartFile.fromBytes( thumbnailUploadData = http.MultipartFile.fromBytes(
"thumbnailData",
List.from(thumbnailData), List.from(thumbnailData),
filename: fileNameWithoutPath, filename: fileNameWithoutPath,
contentType: MediaType( contentType: MediaType(
@ -83,39 +71,37 @@ class BackupService {
"jpeg", "jpeg",
), ),
); );
// Send thumbnail data if it is exist
formData = FormData.fromMap({
'deviceAssetId': entity.id,
'deviceId': deviceId,
'assetType': _getAssetType(entity.type),
'createdAt': entity.createDateTime.toIso8601String(),
'modifiedAt': entity.modifiedDateTime.toIso8601String(),
'isFavorite': entity.isFavorite,
'fileExtension': fileExtension,
'duration': entity.videoDuration,
'thumbnailData': [thumbnailUploadData],
'assetData': [assetRawUploadData]
});
} }
Response res = await dio.post( var box = Hive.box(userInfoBox);
'$savedEndpoint/asset/upload',
data: formData, var req = MultipartRequest('POST', Uri.parse('$savedEndpoint/asset/upload'),
cancelToken: cancelToken, onProgress: ((bytes, totalBytes) => uploadProgress(bytes, totalBytes)));
onSendProgress: (sent, total) => uploadProgress(sent, total), req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
);
req.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId;
req.fields['assetType'] = _getAssetType(entity.type);
req.fields['createdAt'] = entity.createDateTime.toIso8601String();
req.fields['modifiedAt'] = entity.modifiedDateTime.toIso8601String();
req.fields['isFavorite'] = entity.isFavorite.toString();
req.fields['fileExtension'] = fileExtension;
req.fields['duration'] = entity.videoDuration.toString();
if (thumbnailUploadData != null) {
req.files.add(thumbnailUploadData);
}
req.files.add(assetRawUploadData);
var res = await req.send(cancellationToken: cancelToken);
if (res.statusCode == 201) { if (res.statusCode == 201) {
singleAssetDoneCb(entity.id, deviceId); singleAssetDoneCb(entity.id, deviceId);
} }
} }
} on DioError catch (e) { } on http.CancelledException {
debugPrint("DioError backupAsset: ${e.response}"); debugPrint("Backup was cancelled by the user");
if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) { return;
return;
}
continue;
} catch (e) { } catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}"); debugPrint("ERROR backupAsset: ${e.toString()}");
continue; continue;
@ -150,3 +136,35 @@ class BackupService {
return DeviceInfoRemote.fromJson(res.toString()); return DeviceInfoRemote.fromJson(res.toString());
} }
} }
class MultipartRequest extends http.MultipartRequest {
/// Creates a new [MultipartRequest].
MultipartRequest(
String method,
Uri url, {
required this.onProgress,
}) : super(method, url);
final void Function(int bytes, int totalBytes) onProgress;
/// Freezes all mutable fields and returns a
/// single-subscription [http.ByteStream]
/// that will emit the request body.
@override
http.ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return http.ByteStream(stream);
}
}

View File

@ -141,6 +141,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
cancellation_token:
dependency: transitive
description:
name: cancellation_token
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
cancellation_token_http:
dependency: "direct main"
description:
name: cancellation_token_http
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -437,7 +451,7 @@ packages:
source: hosted source: hosted
version: "0.15.0" version: "0.15.0"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"

View File

@ -40,6 +40,8 @@ dependencies:
equatable: ^2.0.3 equatable: ^2.0.3
image_picker: ^0.8.5+3 image_picker: ^0.8.5+3
url_launcher: ^6.1.3 url_launcher: ^6.1.3
http: 0.13.4
cancellation_token_http: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: