From 7f2b398c4223c8d052c9511b304e70bab39e00a8 Mon Sep 17 00:00:00 2001 From: PuPha Date: Tue, 20 Feb 2024 10:38:11 +0700 Subject: [PATCH 1/5] fix(download): download multipart --- lib/service/feralfile_service.dart | 2 +- lib/util/file_helper.dart | 83 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/lib/service/feralfile_service.dart b/lib/service/feralfile_service.dart index af0aad4fd..15185d1be 100644 --- a/lib/service/feralfile_service.dart +++ b/lib/service/feralfile_service.dart @@ -347,7 +347,7 @@ class FeralFileServiceImpl extends FeralFileService { signature: signatureHex, owner: ownerAddress, ); - final file = await FileHelper.downloadFile(url); + final file = await FileHelper.downloadFileMultipart(url); return file; } catch (e) { log.info('Error downloading artwork: $e'); diff --git a/lib/util/file_helper.dart b/lib/util/file_helper.dart index 02e1ee7d0..8cb6222c2 100644 --- a/lib/util/file_helper.dart +++ b/lib/util/file_helper.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:autonomy_flutter/util/log.dart'; import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; import 'package:http/http.dart' as http; import 'package:image_gallery_saver/image_gallery_saver.dart'; import 'package:path_provider/path_provider.dart'; @@ -9,6 +11,11 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:share_plus/share_plus.dart'; class FileHelper { + static final dio = Dio(); + + static Future getDownloadDir() async => + await getApplicationDocumentsDirectory(); + static Future saveImageToGallery(Uint8List data, String name) async { final response = await ImageGallerySaver.saveImage(data, name: name, isReturnImagePathOfIOS: true); @@ -33,6 +40,82 @@ class FileHelper { return file; } + static Future _getFileSize(Map headers) async => + int.parse(headers['content-length'] ?? '0'); + + static Future _getFileName(Map headers) async { + final fileName = headers['content-disposition'] + ?.split(';') + .firstWhereOrNull((element) => element.contains('filename')) + ?.split('=')[1] + .replaceAll('"', '') ?? + 'file'; + return fileName; + } + + static String _getPartFilePath(String filePath, int partNum) => + '$filePath.part$partNum'; + + static Future _clearPartFiles(String filePath, int numParts) async { + for (int i = 0; i < numParts; i++) { + final partFile = File(_getPartFilePath(filePath, i)); + if (partFile.existsSync()) { + await partFile.delete(); + } + } + } + + static Future downloadFileMultipart(String fullUrl) async { + log.info('Downloading file: $fullUrl'); + final dir = await getDownloadDir(); + final savePath = '${dir.path}/Downloads/'; + final request = http.MultipartRequest('GET', Uri.parse(fullUrl)); + final response = await request.send(); + + // Get the file size + final int fileSize = await _getFileSize(response.headers); + final String fileName = await _getFileName(response.headers); + final filePath = savePath + fileName; + + // Calculate the number of parts + const partSize = 5 * 1024 * 1024; + + final int numParts = (fileSize / partSize).ceil(); + try { + // Perform multipart download + await Future.wait(List.generate(numParts, (i) => i).map((i) async { + final int start = i * partSize; + final int end = (i + 1) * partSize - 1; + + await dio.download( + fullUrl, + _getPartFilePath(filePath, i), + options: Options( + headers: { + HttpHeaders.rangeHeader: 'bytes=$start-$end', + }, + ), + ); + log.info('Downloaded part $i/$numParts'); + })); + + // Concatenate parts to create the final file + final outputFile = File(filePath); + final IOSink sink = outputFile.openWrite(mode: FileMode.writeOnlyAppend); + for (int i = 0; i < numParts; i++) { + final File partFile = File(_getPartFilePath(filePath, i)); + await sink.addStream(partFile.openRead()); + } + await sink.close(); + await _clearPartFiles(filePath, numParts); + return outputFile; + } catch (e) { + log.info('Error downloading file: $e'); + await _clearPartFiles(filePath, numParts); + } + return null; + } + static Future downloadFile( String fullUrl, ) async { From 6297d97dcd706b7e09edcd415ad91cdcbb578123 Mon Sep 17 00:00:00 2001 From: PuPha Date: Tue, 20 Feb 2024 15:08:13 +0700 Subject: [PATCH 2/5] Refactor --- lib/service/feralfile_service.dart | 2 +- lib/util/download_helper.dart | 104 +++++++++++++++++++++++++++++ lib/util/file_helper.dart | 99 --------------------------- 3 files changed, 105 insertions(+), 100 deletions(-) create mode 100644 lib/util/download_helper.dart diff --git a/lib/service/feralfile_service.dart b/lib/service/feralfile_service.dart index 15185d1be..e95699670 100644 --- a/lib/service/feralfile_service.dart +++ b/lib/service/feralfile_service.dart @@ -347,7 +347,7 @@ class FeralFileServiceImpl extends FeralFileService { signature: signatureHex, owner: ownerAddress, ); - final file = await FileHelper.downloadFileMultipart(url); + final file = await DownloadHelper.fileChunkedDownload(url); return file; } catch (e) { log.info('Error downloading artwork: $e'); diff --git a/lib/util/download_helper.dart b/lib/util/download_helper.dart new file mode 100644 index 000000000..8f15fe168 --- /dev/null +++ b/lib/util/download_helper.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:autonomy_flutter/util/file_helper.dart'; +import 'package:autonomy_flutter/util/log.dart'; +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:http/http.dart' as http; + +class DownloadHelper { + static final _dio = Dio(); + + static Future _getFileSize(Map headers) async => + int.parse(headers['content-length'] ?? '0'); + + static Future _getFileName(Map headers) async { + final fileName = headers['content-disposition'] + ?.split(';') + .firstWhereOrNull((element) => element.contains('filename')) + ?.split('=')[1] + .replaceAll('"', '') ?? + 'file'; + return fileName; + } + + static String _getPartFilePath(String filePath, int partNum) => + '$filePath.part$partNum'; + + static Future _clearPartFiles(String filePath, int numParts) async { + for (int i = 0; i < numParts; i++) { + final partFile = File(_getPartFilePath(filePath, i)); + if (partFile.existsSync()) { + await partFile.delete(); + } + } + } + + static Future fileChunkedDownload(String fullUrl) async { + log.info('Downloading file: $fullUrl'); + final dir = await FileHelper.getDownloadDir(); + final savePath = '${dir.path}/Downloads/'; + final request = http.MultipartRequest('GET', Uri.parse(fullUrl)); + final response = await request.send(); + + // Get the file size + final int fileSize = await _getFileSize(response.headers); + final String fileName = await _getFileName(response.headers); + final filePath = savePath + fileName; + + // Calculate the number of parts + const partSize = 5 * 1024 * 1024; + + final int numParts = (fileSize / partSize).ceil(); + try { + // Perform multipart download + await Future.wait(List.generate(numParts, (i) => i).map((i) async { + final int start = i * partSize; + final int end = (i + 1) * partSize - 1; + + await _dio.download( + fullUrl, + _getPartFilePath(filePath, i), + options: Options( + headers: { + HttpHeaders.rangeHeader: 'bytes=$start-$end', + }, + ), + ); + log.info('Downloaded part $i/$numParts'); + })); + + // Concatenate parts to create the final file + final outputFile = File(filePath); + final IOSink sink = outputFile.openWrite(mode: FileMode.writeOnlyAppend); + for (int i = 0; i < numParts; i++) { + final File partFile = File(_getPartFilePath(filePath, i)); + await sink.addStream(partFile.openRead()); + } + await sink.close(); + await _clearPartFiles(filePath, numParts); + return outputFile; + } catch (e) { + log.info('Error downloading file: $e'); + await _clearPartFiles(filePath, numParts); + } + return null; + } + + static Future downloadFile( + String fullUrl, + ) async { + final response = await http.get(Uri.parse(fullUrl)); + final bytes = response.bodyBytes; + final header = response.headers; + final filename = header['x-amz-meta-filename'] ?? + header['content-disposition'] + ?.split(';') + .firstWhereOrNull((element) => element.contains('filename')) + ?.split('=')[1] + .replaceAll('"', '') ?? + 'file'; + final file = await FileHelper.saveFileToDownloadDir(bytes, filename); + return file; + } +} diff --git a/lib/util/file_helper.dart b/lib/util/file_helper.dart index 8cb6222c2..4197695ca 100644 --- a/lib/util/file_helper.dart +++ b/lib/util/file_helper.dart @@ -1,18 +1,12 @@ import 'dart:io'; import 'dart:typed_data'; -import 'package:autonomy_flutter/util/log.dart'; -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; -import 'package:http/http.dart' as http; import 'package:image_gallery_saver/image_gallery_saver.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:share_plus/share_plus.dart'; class FileHelper { - static final dio = Dio(); - static Future getDownloadDir() async => await getApplicationDocumentsDirectory(); @@ -40,99 +34,6 @@ class FileHelper { return file; } - static Future _getFileSize(Map headers) async => - int.parse(headers['content-length'] ?? '0'); - - static Future _getFileName(Map headers) async { - final fileName = headers['content-disposition'] - ?.split(';') - .firstWhereOrNull((element) => element.contains('filename')) - ?.split('=')[1] - .replaceAll('"', '') ?? - 'file'; - return fileName; - } - - static String _getPartFilePath(String filePath, int partNum) => - '$filePath.part$partNum'; - - static Future _clearPartFiles(String filePath, int numParts) async { - for (int i = 0; i < numParts; i++) { - final partFile = File(_getPartFilePath(filePath, i)); - if (partFile.existsSync()) { - await partFile.delete(); - } - } - } - - static Future downloadFileMultipart(String fullUrl) async { - log.info('Downloading file: $fullUrl'); - final dir = await getDownloadDir(); - final savePath = '${dir.path}/Downloads/'; - final request = http.MultipartRequest('GET', Uri.parse(fullUrl)); - final response = await request.send(); - - // Get the file size - final int fileSize = await _getFileSize(response.headers); - final String fileName = await _getFileName(response.headers); - final filePath = savePath + fileName; - - // Calculate the number of parts - const partSize = 5 * 1024 * 1024; - - final int numParts = (fileSize / partSize).ceil(); - try { - // Perform multipart download - await Future.wait(List.generate(numParts, (i) => i).map((i) async { - final int start = i * partSize; - final int end = (i + 1) * partSize - 1; - - await dio.download( - fullUrl, - _getPartFilePath(filePath, i), - options: Options( - headers: { - HttpHeaders.rangeHeader: 'bytes=$start-$end', - }, - ), - ); - log.info('Downloaded part $i/$numParts'); - })); - - // Concatenate parts to create the final file - final outputFile = File(filePath); - final IOSink sink = outputFile.openWrite(mode: FileMode.writeOnlyAppend); - for (int i = 0; i < numParts; i++) { - final File partFile = File(_getPartFilePath(filePath, i)); - await sink.addStream(partFile.openRead()); - } - await sink.close(); - await _clearPartFiles(filePath, numParts); - return outputFile; - } catch (e) { - log.info('Error downloading file: $e'); - await _clearPartFiles(filePath, numParts); - } - return null; - } - - static Future downloadFile( - String fullUrl, - ) async { - final response = await http.get(Uri.parse(fullUrl)); - final bytes = response.bodyBytes; - final header = response.headers; - final filename = header['x-amz-meta-filename'] ?? - header['content-disposition'] - ?.split(';') - .firstWhereOrNull((element) => element.contains('filename')) - ?.split('=')[1] - .replaceAll('"', '') ?? - 'file'; - final file = await FileHelper.saveFileToDownloadDir(bytes, filename); - return file; - } - static Future shareFile(File file, {bool deleteAfterShare = false, Function? onShareSuccess}) async { final result = await Share.shareXFiles([XFile(file.path)]); From 86a98d308004ca43e4ae04d7477dfd4d28a7a2a0 Mon Sep 17 00:00:00 2001 From: PuPha Date: Tue, 20 Feb 2024 16:59:25 +0700 Subject: [PATCH 3/5] Add progress download --- lib/screen/detail/artwork_detail_page.dart | 41 +++++++++++++++------- lib/service/feralfile_service.dart | 11 +++--- lib/util/download_helper.dart | 27 ++++++++++++-- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/lib/screen/detail/artwork_detail_page.dart b/lib/screen/detail/artwork_detail_page.dart index 1cb01985c..0179cadbf 100644 --- a/lib/screen/detail/artwork_detail_page.dart +++ b/lib/screen/detail/artwork_detail_page.dart @@ -72,6 +72,7 @@ class _ArtworkDetailPageState extends State with AfterLayoutMixin { late ScrollController _scrollController; late bool withSharing; + ValueNotifier downloadProgress = ValueNotifier(0); HashSet _accountNumberHash = HashSet.identity(); AssetToken? currentAsset; @@ -456,7 +457,7 @@ class _ArtworkDetailPageState extends State unawaited(UIHelper.showDrawerAction( context, options: [ - if (asset.shouldShowDownloadArtwork && !isViewOnly) + if (asset.shouldShowDownloadArtwork && !isViewOnly || true) OptionItem( title: 'download_artwork'.tr(), icon: SvgPicture.asset('assets/images/download_artwork.svg'), @@ -467,22 +468,38 @@ class _ArtworkDetailPageState extends State BlendMode.srcIn, ), ), - iconOnProcessing: const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(AppColor.disabledColor), - strokeWidth: 1, - ), - ), + iconOnProcessing: ValueListenableBuilder( + valueListenable: downloadProgress, + builder: (context, double value, child) { + return SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + value: value <= 0 ? null : value, + valueColor: value <= 0 + ? null + : AlwaysStoppedAnimation(Colors.blue), + backgroundColor: + value <= 0 ? null : AppColor.disabledColor, + color: AppColor.disabledColor, + strokeWidth: 2, + ), + ); + }), onTap: () async { try { - final file = - await _feralfileService.downloadFeralfileArtwork(asset); + final file = await _feralfileService.downloadFeralfileArtwork( + asset, onReceiveProgress: (received, total) { + setState(() { + downloadProgress.value = received / total; + }); + }); if (!mounted) { return; } + setState(() { + downloadProgress.value = 0; + }); Navigator.of(context).pop(); if (file != null) { await FileHelper.shareFile(file, deleteAfterShare: true); diff --git a/lib/service/feralfile_service.dart b/lib/service/feralfile_service.dart index e95699670..17fe62707 100644 --- a/lib/service/feralfile_service.dart +++ b/lib/service/feralfile_service.dart @@ -18,8 +18,8 @@ import 'package:autonomy_flutter/screen/claim/claim_token_page.dart'; import 'package:autonomy_flutter/service/account_service.dart'; import 'package:autonomy_flutter/util/asset_token_ext.dart'; import 'package:autonomy_flutter/util/custom_exception.dart'; +import 'package:autonomy_flutter/util/download_helper.dart'; import 'package:autonomy_flutter/util/feralfile_extension.dart'; -import 'package:autonomy_flutter/util/file_helper.dart'; import 'package:autonomy_flutter/util/log.dart'; import 'package:autonomy_flutter/util/wallet_storage_ext.dart'; import 'package:collection/collection.dart'; @@ -78,7 +78,8 @@ abstract class FeralFileService { Future getArtwork(String artworkId); - Future downloadFeralfileArtwork(AssetToken assetToken); + Future downloadFeralfileArtwork(AssetToken assetToken, + {Function(int received, int total)? onReceiveProgress}); } class FeralFileServiceImpl extends FeralFileService { @@ -322,7 +323,8 @@ class FeralFileServiceImpl extends FeralFileService { } @override - Future downloadFeralfileArtwork(AssetToken assetToken) async { + Future downloadFeralfileArtwork(AssetToken assetToken, + {Function(int received, int total)? onReceiveProgress}) async { try { final artwork = await injector() .getArtwork(assetToken.tokenId ?? ''); @@ -347,7 +349,8 @@ class FeralFileServiceImpl extends FeralFileService { signature: signatureHex, owner: ownerAddress, ); - final file = await DownloadHelper.fileChunkedDownload(url); + final file = DownloadHelper.fileChunkedDownload(url, + onReceiveProgress: onReceiveProgress); return file; } catch (e) { log.info('Error downloading artwork: $e'); diff --git a/lib/util/download_helper.dart b/lib/util/download_helper.dart index 8f15fe168..f0884d17e 100644 --- a/lib/util/download_helper.dart +++ b/lib/util/download_helper.dart @@ -34,7 +34,8 @@ class DownloadHelper { } } - static Future fileChunkedDownload(String fullUrl) async { + static Future fileChunkedDownload(String fullUrl, + {Function(int received, int total)? onReceiveProgress}) async { log.info('Downloading file: $fullUrl'); final dir = await FileHelper.getDownloadDir(); final savePath = '${dir.path}/Downloads/'; @@ -45,6 +46,7 @@ class DownloadHelper { final int fileSize = await _getFileSize(response.headers); final String fileName = await _getFileName(response.headers); final filePath = savePath + fileName; + int received = 0; // Calculate the number of parts const partSize = 5 * 1024 * 1024; @@ -52,7 +54,7 @@ class DownloadHelper { final int numParts = (fileSize / partSize).ceil(); try { // Perform multipart download - await Future.wait(List.generate(numParts, (i) => i).map((i) async { + final downloadFeatures = List.generate(numParts, (i) => i).map((i) async { final int start = i * partSize; final int end = (i + 1) * partSize - 1; @@ -65,8 +67,27 @@ class DownloadHelper { }, ), ); + received += partSize; + if (onReceiveProgress != null) { + onReceiveProgress(received, fileSize); + } + log.info('Downloaded part $i/$numParts'); - })); + }); + + final batchSize = 10; + final batches = >>[]; + List> batch = >[]; + for (final future in downloadFeatures) { + batch.add(future); + if (batch.length == batchSize) { + batches.add(batch); + batch = >[]; + } + } + for (final batch in batches) { + await Future.wait(batch); + } // Concatenate parts to create the final file final outputFile = File(filePath); From 71f9fb408a5b45e5bf932735b76f3f55babd234c Mon Sep 17 00:00:00 2001 From: PuPha Date: Tue, 20 Feb 2024 17:10:18 +0700 Subject: [PATCH 4/5] Update chunk size --- lib/util/download_helper.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/util/download_helper.dart b/lib/util/download_helper.dart index f0884d17e..27cf05750 100644 --- a/lib/util/download_helper.dart +++ b/lib/util/download_helper.dart @@ -34,6 +34,11 @@ class DownloadHelper { } } + static int _getChunkSize(int fileSize) { + const int maxChunkSize = 1024 * 1024; + return fileSize > maxChunkSize ? maxChunkSize : fileSize; + } + static Future fileChunkedDownload(String fullUrl, {Function(int received, int total)? onReceiveProgress}) async { log.info('Downloading file: $fullUrl'); @@ -49,7 +54,7 @@ class DownloadHelper { int received = 0; // Calculate the number of parts - const partSize = 5 * 1024 * 1024; + final partSize = _getChunkSize(fileSize); final int numParts = (fileSize / partSize).ceil(); try { @@ -85,6 +90,9 @@ class DownloadHelper { batch = >[]; } } + if (batch.isNotEmpty) { + batches.add(batch); + } for (final batch in batches) { await Future.wait(batch); } From ce412af786df839e242b072c15fc421fdc0b1734 Mon Sep 17 00:00:00 2001 From: PuPha Date: Tue, 20 Feb 2024 17:27:31 +0700 Subject: [PATCH 5/5] fix lint --- lib/screen/detail/artwork_detail_page.dart | 30 ++++++++++------------ lib/util/download_helper.dart | 2 +- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/lib/screen/detail/artwork_detail_page.dart b/lib/screen/detail/artwork_detail_page.dart index a7e9aa338..e252e2f2f 100644 --- a/lib/screen/detail/artwork_detail_page.dart +++ b/lib/screen/detail/artwork_detail_page.dart @@ -487,22 +487,20 @@ class _ArtworkDetailPageState extends State ), iconOnProcessing: ValueListenableBuilder( valueListenable: downloadProgress, - builder: (context, double value, child) { - return SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - value: value <= 0 ? null : value, - valueColor: value <= 0 - ? null - : AlwaysStoppedAnimation(Colors.blue), - backgroundColor: - value <= 0 ? null : AppColor.disabledColor, - color: AppColor.disabledColor, - strokeWidth: 2, - ), - ); - }), + builder: (context, double value, child) => SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + value: value <= 0 ? null : value, + valueColor: value <= 0 + ? null + : const AlwaysStoppedAnimation(Colors.blue), + backgroundColor: + value <= 0 ? null : AppColor.disabledColor, + color: AppColor.disabledColor, + strokeWidth: 2, + ), + )), onTap: () async { try { final file = await _feralfileService.downloadFeralfileArtwork( diff --git a/lib/util/download_helper.dart b/lib/util/download_helper.dart index 27cf05750..768d8e3cd 100644 --- a/lib/util/download_helper.dart +++ b/lib/util/download_helper.dart @@ -80,7 +80,7 @@ class DownloadHelper { log.info('Downloaded part $i/$numParts'); }); - final batchSize = 10; + const batchSize = 10; final batches = >>[]; List> batch = >[]; for (final future in downloadFeatures) {