diff --git a/android/fastlane/metadata/android/en-US/changelogs/171.txt b/android/fastlane/metadata/android/en-US/changelogs/171.txt new file mode 100644 index 0000000000..4e522f08a4 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/171.txt @@ -0,0 +1,5 @@ +- Introduced fallback transaction ID support for handling unresolved resource paths in manifests +- Enhanced file upload efficiency by directly uploading small files and using chunked uploads only for files larger than 5MiB +- Updated wallet switch dialog to use the new modal components +- Fixed issue preventing users from renaming a drive to a name already in use +- Fixed an issue preventing downloads for some users in Brave browser diff --git a/lib/app_shell.dart b/lib/app_shell.dart index 438f590f60..f25eae2228 100644 --- a/lib/app_shell.dart +++ b/lib/app_shell.dart @@ -1,3 +1,4 @@ +import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; import 'package:ardrive/components/profile_card.dart'; @@ -46,14 +47,22 @@ class AppShellState extends State { @override void initState() { onArConnectWalletSwitch(() { + logger.d('Wallet switch detected'); context.read().isCurrentProfileArConnect().then( (isCurrentProfileArConnect) { if (_showWalletSwitchDialog) { if (isCurrentProfileArConnect) { - showDialog( - context: context, - builder: (context) => const WalletSwitchDialog(), - ); + context.read().isUserLoggedIn().then((isLoggedIn) { + context.read().logoutIfWalletMismatch(); + if (isLoggedIn) { + logger.d('Wallet switch detected while logged in' + ' to ArConnect. Showing wallet switch dialog.'); + showArDriveDialog( + context, + content: const WalletSwitchDialog(), + ); + } + }); } else { logger.d('Wallet switch detected while not logged in' ' to ArConnect. Ignoring.'); diff --git a/lib/authentication/ardrive_auth.dart b/lib/authentication/ardrive_auth.dart index 0918bab9bb..258c90fd26 100644 --- a/lib/authentication/ardrive_auth.dart +++ b/lib/authentication/ardrive_auth.dart @@ -231,16 +231,17 @@ class ArDriveAuthImpl implements ArDriveAuth { logger.d('Logging out user'); try { + await _userRepository.deleteUser(); + if (_currentUser != null) { + await _disconnectFromArConnect(); + (await _metadataCache).clear(); await _secureKeyValueStore.remove('password'); await _secureKeyValueStore.remove('biometricEnabled'); - currentUser = null; - await _disconnectFromArConnect(); } - - await _userRepository.deleteUser(); + await _databaseHelpers.deleteAllTables(); - (await _metadataCache).clear(); + currentUser = null; _userStreamController.add(null); } catch (e, stacktrace) { logger.e('Failed to logout user', e, stacktrace); diff --git a/lib/authentication/login/blocs/login_bloc.dart b/lib/authentication/login/blocs/login_bloc.dart index d481619321..c755f9884f 100644 --- a/lib/authentication/login/blocs/login_bloc.dart +++ b/lib/authentication/login/blocs/login_bloc.dart @@ -571,6 +571,7 @@ class LoginBloc extends Bloc { } onArConnectWalletSwitch(() async { + logger.d('Wallet switch detected on LoginBloc'); final isUserLoggedIng = await _arDriveAuth.isUserLoggedIn(); if (isUserLoggedIng && !_isArConnectWallet()) { logger.d( diff --git a/lib/blocs/create_manifest/create_manifest_cubit.dart b/lib/blocs/create_manifest/create_manifest_cubit.dart index 02532bb9bd..26401f2042 100644 --- a/lib/blocs/create_manifest/create_manifest_cubit.dart +++ b/lib/blocs/create_manifest/create_manifest_cubit.dart @@ -71,6 +71,7 @@ class CreateManifestCubit extends Cubit { canUpload: canUpload, freeUpload: info.isFreeThanksToTurbo, assignedName: (state as CreateManifestUploadReview).assignedName, + fallbackTxId: (state as CreateManifestUploadReview).fallbackTxId, ), ); } @@ -139,6 +140,7 @@ class CreateManifestCubit extends Cubit { CreateManifestFolderLoadSuccess( viewingRootFolder: f.folder.parentFolderId == null, viewingFolder: f, + enableManifestCreationButton: _getEnableManifestCreationButton(), ), ), ); @@ -249,6 +251,7 @@ class CreateManifestCubit extends Cubit { manifestName: manifestName, rootFolderNode: rootFolderNode, driveId: _drive.id, + fallbackTxId: _getFallbackTxId(), ); ARNSUndername? undername = getSelectedUndername(); @@ -264,6 +267,7 @@ class CreateManifestCubit extends Cubit { existingManifestFileId: existingManifestFileId, assignedName: undername != null ? getLiteralARNSRecordName(undername) : null, + fallbackTxId: _getFallbackTxId(), ), ); } catch (e) { @@ -303,6 +307,7 @@ class CreateManifestCubit extends Cubit { createManifestUploadReview.existingManifestFileId, uploadType: uploadType, wallet: _auth.currentUser.wallet, + fallbackTxId: _getFallbackTxId(), ), processId: _selectedAntRecord?.processId, undername: getSelectedUndername(), @@ -367,6 +372,35 @@ class CreateManifestCubit extends Cubit { prepareManifestTx(manifestName: manifestName); } + TxID? _fallbackTxId; + + void setFallbackTxId(TxID txId, {bool emitState = true}) { + _fallbackTxId = txId; + + if (emitState) { + emit( + (state as CreateManifestFolderLoadSuccess).copyWith( + fallbackTxId: _getFallbackTxId(), + enableManifestCreationButton: _getEnableManifestCreationButton(), + ), + ); + } + } + + TxID? _getFallbackTxId() { + if (_fallbackTxId == null || _fallbackTxId!.isEmpty) { + return null; + } + + return _fallbackTxId; + } + + bool _getEnableManifestCreationButton() { + return _getFallbackTxId() == null || + _getFallbackTxId()!.isEmpty || + isValidArweaveTxId(_getFallbackTxId()!); + } + @override Future close() async { await _selectedFolderSubscription?.cancel(); diff --git a/lib/blocs/create_manifest/create_manifest_state.dart b/lib/blocs/create_manifest/create_manifest_state.dart index b8b6bc1272..64880e408a 100644 --- a/lib/blocs/create_manifest/create_manifest_state.dart +++ b/lib/blocs/create_manifest/create_manifest_state.dart @@ -13,14 +13,36 @@ class CreateManifestInitial extends CreateManifestState {} class CreateManifestFolderLoadSuccess extends CreateManifestState { final bool viewingRootFolder; final FolderWithContents viewingFolder; + final bool enableManifestCreationButton; + final String? fallbackTxId; CreateManifestFolderLoadSuccess({ required this.viewingRootFolder, required this.viewingFolder, + required this.enableManifestCreationButton, + this.fallbackTxId, }); + CreateManifestFolderLoadSuccess copyWith({ + bool? enableManifestCreationButton, + String? fallbackTxId, + }) { + return CreateManifestFolderLoadSuccess( + viewingRootFolder: viewingRootFolder, + viewingFolder: viewingFolder, + enableManifestCreationButton: + enableManifestCreationButton ?? this.enableManifestCreationButton, + fallbackTxId: fallbackTxId ?? this.fallbackTxId, + ); + } + @override - List get props => [viewingRootFolder, viewingFolder]; + List get props => [ + viewingRootFolder, + viewingFolder, + enableManifestCreationButton, + fallbackTxId, + ]; } /// User has selected a folder and we are checking for name conflicts @@ -109,6 +131,7 @@ class CreateManifestUploadReview extends CreateManifestState { final String? existingManifestFileId; final bool canUpload; final String? assignedName; + final String? fallbackTxId; CreateManifestUploadReview({ required this.manifestSize, @@ -122,6 +145,7 @@ class CreateManifestUploadReview extends CreateManifestState { this.existingManifestFileId, this.canUpload = false, this.assignedName, + this.fallbackTxId, }); @override @@ -136,6 +160,7 @@ class CreateManifestUploadReview extends CreateManifestState { parentFolder, existingManifestFileId, assignedName, + fallbackTxId, ]; CreateManifestUploadReview copyWith({ @@ -150,6 +175,7 @@ class CreateManifestUploadReview extends CreateManifestState { String? existingManifestFileId, bool? canUpload, String? assignedName, + String? fallbackTxId, }) { return CreateManifestUploadReview( manifestSize: manifestSize ?? this.manifestSize, @@ -165,6 +191,7 @@ class CreateManifestUploadReview extends CreateManifestState { existingManifestFileId ?? this.existingManifestFileId, canUpload: canUpload ?? this.canUpload, assignedName: assignedName ?? this.assignedName, + fallbackTxId: fallbackTxId ?? this.fallbackTxId, ); } } diff --git a/lib/blocs/drive_rename/drive_rename_cubit.dart b/lib/blocs/drive_rename/drive_rename_cubit.dart index 7399344361..6febef2fee 100644 --- a/lib/blocs/drive_rename/drive_rename_cubit.dart +++ b/lib/blocs/drive_rename/drive_rename_cubit.dart @@ -35,6 +35,7 @@ class DriveRenameCubit extends Cubit { Future submit({ required String newName, + bool proceedIfHasConflicts = false, }) async { try { final profile = _profileCubit.state as ProfileLoggedIn; @@ -47,7 +48,7 @@ class DriveRenameCubit extends Cubit { return; } - if (await _fileWithSameNameExistis(newName)) { + if (await _fileWithSameNameExistis(newName) && !proceedIfHasConflicts) { final previousState = state; emit(DriveNameAlreadyExists(newName)); emit(previousState); diff --git a/lib/blocs/file_download/personal_file_download_cubit.dart b/lib/blocs/file_download/personal_file_download_cubit.dart index 420e74ab7e..5b3f65ea22 100644 --- a/lib/blocs/file_download/personal_file_download_cubit.dart +++ b/lib/blocs/file_download/personal_file_download_cubit.dart @@ -43,12 +43,17 @@ class ProfileFileDownloadCubit extends FileDownloadCubit { super(FileDownloadStarting()); Future verifyUploadLimitationsAndDownload(SecretKey? cipherKey) async { - if (await AppPlatform.isSafari()) { - if (_file.size > publicDownloadSafariSizeLimit) { - emit(const FileDownloadFailure( - FileDownloadFailureReason.browserDoesNotSupportLargeDownloads)); - return; + try { + if (await AppPlatform.isSafari()) { + if (_file.size > publicDownloadSafariSizeLimit) { + emit(const FileDownloadFailure( + FileDownloadFailureReason.browserDoesNotSupportLargeDownloads)); + return; + } } + } catch (e) { + logger.d( + 'Error verifying upload limitations and downloading file... proceeding with download'); } download(cipherKey); diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index c34399ac9f..2693822338 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -161,6 +161,7 @@ class UploadCubit extends Cubit { ) .getSingle(); + /// If the manifest has a fallback tx id, we need to reuse it await _createManifestCubit.prepareManifestTx( manifestName: manifestFileEntry.name, folderId: manifestFileEntry.parentFolderId, @@ -223,6 +224,13 @@ class UploadCubit extends Cubit { ) .getSingle(); + if (manifestFileEntry.fallbackTxId != null) { + _createManifestCubit.setFallbackTxId( + manifestFileEntry.fallbackTxId!, + emitState: false, + ); + } + await _createManifestCubit.prepareManifestTx( manifestName: manifestFileEntry.name, folderId: manifestFileEntry.parentFolderId, @@ -230,13 +238,11 @@ class UploadCubit extends Cubit { ); emit(UploadingManifests( - manifestFiles: manifestModels, + manifestFiles: manifestModels, completedCount: completedCount, )); - await _createManifestCubit.uploadManifest( - method: _manifestUploadMethod, - ); + await _createManifestCubit.uploadManifest(method: _manifestUploadMethod); final manifestFile = await _driveDao .fileById( diff --git a/lib/components/create_manifest_form.dart b/lib/components/create_manifest_form.dart index 9e19d676d9..e4c5bb2b8b 100644 --- a/lib/components/create_manifest_form.dart +++ b/lib/components/create_manifest_form.dart @@ -560,6 +560,30 @@ class _CreateManifestFormState extends State { filesize(state.manifestSize), style: textStyle, ), + const SizedBox(height: 8), + if (state.fallbackTxId != null) ...[ + RichText( + text: TextSpan( + style: textStyle, + children: [ + TextSpan( + text: 'Fallback TxId\n', + style: typography.paragraphLarge( + color: colorTokens.textHigh, + fontWeight: ArFontWeight.bold, + ), + ), + TextSpan( + text: state.fallbackTxId, + style: typography.paragraphSmall( + color: colorTokens.textMid, + fontWeight: ArFontWeight.semiBold, + ), + ), + ], + ), + ), + ], ], ), ), @@ -874,6 +898,53 @@ class _CreateManifestFormState extends State { ), ), const Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ArDriveClickArea( + child: ArDriveTooltip( + message: + 'The fallback specifies a default content to show if the requested path cannot be found.\nThis is typically used for handling missing pages or errors, like a \'404 Not Found\' page.', + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Fallback TxId', + style: typography.paragraphNormal( + color: colorTokens.textLow, + fontWeight: ArFontWeight.semiBold, + ), + ), + const SizedBox(width: 8), + ArDriveIcons.info( + size: 16, + color: colorTokens.iconMid, + ), + ], + ), + ), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ArDriveTextFieldNew( + hintText: 'TxId', + onChanged: (value) { + cubit.setFallbackTxId(value); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return null; + } + + return isValidArweaveTxId(value) ? null : 'Invalid TxId'; + }, + ), + ), + const Divider( + height: 24, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: ArDriveCard( @@ -901,6 +972,7 @@ class _CreateManifestFormState extends State { ), ), action: ModalAction( + isEnable: state.enableManifestCreationButton, action: () => cubit.checkForConflicts(_manifestNameController.text), title: appLocalizationsOf(context).createHereEmphasized, ), diff --git a/lib/components/details_panel.dart b/lib/components/details_panel.dart index d591fd4690..7f48446524 100644 --- a/lib/components/details_panel.dart +++ b/lib/components/details_panel.dart @@ -776,6 +776,21 @@ class _DetailsPanelState extends State { itemTitle: appLocalizationsOf(context).dataTxID, ), sizedBoxHeight16px, + if (item.fallbackTxId != null) + DetailsPanelItem( + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _TxIdTextLink(txId: item.fallbackTxId!), + const SizedBox(width: 12), + CopyButton( + text: item.fallbackTxId!, + ), + ], + ), + itemTitle: 'Manifest Fallback TxID', + ), + sizedBoxHeight16px, if (state is FsEntryFileInfoSuccess) DetailsPanelItem( // TODO: Localize diff --git a/lib/components/drive_rename_form.dart b/lib/components/drive_rename_form.dart index 213ac6abf2..d93440f29b 100644 --- a/lib/components/drive_rename_form.dart +++ b/lib/components/drive_rename_form.dart @@ -77,6 +77,7 @@ class _DriveRenameFormState extends State { showProgressDialog( context, title: appLocalizationsOf(context).renamingDriveEmphasized, + useNewArDriveUI: true, ); } else if (state is DriveRenameSuccess) { context.read().refreshDriveDataTable(); @@ -87,20 +88,35 @@ class _DriveRenameFormState extends State { } else if (state is DriveNameAlreadyExists) { showStandardDialog( context, - title: appLocalizationsOf(context).error, - description: appLocalizationsOf(context).entityAlreadyExists( - state.driveName, - ), + title: 'Warning', + description: + 'A drive with this name already exists. Do you want to proceed?', + useNewArDriveUI: true, + actions: [ + ModalAction( + action: () => Navigator.of(context).pop(), + title: 'Cancel', + ), + ModalAction( + action: () { + Navigator.of(context).pop(); + return context.read().submit( + newName: controller.text, + proceedIfHasConflicts: true, + ); + }, + title: 'Proceed', + ), + ], ); - Navigator.pop(context); } }, - builder: (context, state) => ArDriveStandardModal( + builder: (context, state) => ArDriveStandardModalNew( title: appLocalizationsOf(context).renameDriveEmphasized, content: state is! FsEntryRenameInitializing ? SizedBox( width: kMediumDialogWidth, - child: ArDriveTextField( + child: ArDriveTextFieldNew( controller: controller, autofocus: true, validator: (value) { diff --git a/lib/components/profile_card.dart b/lib/components/profile_card.dart index ff0dafbe9a..ed3e4d6e91 100644 --- a/lib/components/profile_card.dart +++ b/lib/components/profile_card.dart @@ -19,7 +19,6 @@ import 'package:ardrive/user/balance/user_balance_bloc.dart'; import 'package:ardrive/user/download_wallet/download_wallet_modal.dart'; import 'package:ardrive/user/name/presentation/bloc/profile_name_bloc.dart'; import 'package:ardrive/utils/app_localizations_wrapper.dart'; -import 'package:ardrive/utils/logger.dart'; import 'package:ardrive/utils/open_url.dart'; import 'package:ardrive/utils/open_url_utils.dart'; import 'package:ardrive/utils/open_urls.dart'; @@ -694,7 +693,6 @@ class ProfileCardHeader extends StatelessWidget { final maxWidth = _calculateMaxWidth(primaryName, state); final truncatedWalletAddress = _getTruncatedWalletAddress(primaryName, walletAddress); - logger.d('Truncated wallet address: $truncatedWalletAddress'); final tooltipMessage = primaryName.length > 20 ? primaryName : null; return ArDriveTooltip( message: tooltipMessage ?? '', diff --git a/lib/components/wallet_switch_dialog.dart b/lib/components/wallet_switch_dialog.dart index 4f3f99bde3..d9f00dd1e2 100644 --- a/lib/components/wallet_switch_dialog.dart +++ b/lib/components/wallet_switch_dialog.dart @@ -1,42 +1,16 @@ // ignore_for_file: use_build_context_synchronously -import 'package:ardrive/authentication/ardrive_auth.dart'; -import 'package:ardrive/blocs/blocs.dart'; -import 'package:ardrive/components/app_dialog.dart'; import 'package:ardrive/utils/app_localizations_wrapper.dart'; -import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -// TODO: Add the new modal PE-4381 class WalletSwitchDialog extends StatelessWidget { final bool fromAuthPage; const WalletSwitchDialog({super.key, this.fromAuthPage = false}); @override - Widget build(BuildContext context) => AppDialog( - dismissable: false, + Widget build(BuildContext context) => ArDriveStandardModalNew( title: appLocalizationsOf(context).walletSwitch, - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [Text(appLocalizationsOf(context).walletChangeDetected)], - ), - actions: [ - TextButton( - onPressed: () async { - await context.read().logout(); - await context.read().logoutProfile(); - - Navigator.pop(context); - - if (fromAuthPage) { - triggerHTMLPageReload(); - context.read().promptForWallet(); - } - }, - child: Text(appLocalizationsOf(context).logOut), - ) - ], + description: appLocalizationsOf(context).walletChangeDetected, ); } diff --git a/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart index e833458ede..a7b53539b8 100644 --- a/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart +++ b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart @@ -113,6 +113,7 @@ class ThumbnailRepository { await _driveDao.driveById(driveId: fileEntry.driveId).getSingle(); if (drive.isPrivate) { + logger.d('Drive is private. Getting drive key'); final driveKey = await _driveDao.getDriveKey( drive.id, _arDriveAuth.currentUser.cipherKey, @@ -124,6 +125,8 @@ class ThumbnailRepository { ); } + logger.d('Downloading file to memory'); + final bytes = await _arDriveDownloader.downloadToMemory( dataTx: dataTx!, fileSize: fileEntry.size, @@ -136,6 +139,8 @@ class ThumbnailRepository { cipherIvString: dataTx.getTag(EntityTag.cipherIv), ); + logger.d('Generating thumbnail'); + final data = await generateThumbnail(bytes, ThumbnailSize.small); final thumbnailFile = await IOFileAdapter().fromData( @@ -154,6 +159,8 @@ class ThumbnailRepository { originalFileId: fileEntry.id, ); + logger.d('Uploading thumbnail'); + final controller = await _arDriveUploader.uploadThumbnail( thumbnailMetadata: thumbnailMetadata, file: thumbnailFile, @@ -164,6 +171,10 @@ class ThumbnailRepository { Completer completer = Completer(); + controller.onError((error) { + logger.e('Error uploading thumbnail on upload controller', error, StackTrace.current); + }); + controller.onDone((tasks) async { logger.i('Thumbnail uploaded'); diff --git a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart index f0cc89b3e3..7edf597d24 100644 --- a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart +++ b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart @@ -1,5 +1,6 @@ import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; import 'package:ardrive/pages/drive_detail/models/data_table_item.dart'; +import 'package:ardrive/utils/logger.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,7 +29,8 @@ class ThumbnailCreationBloc fileId: event.fileDataTableItem.id); emit(ThumbnailCreationSuccess()); - } catch (e) { + } catch (e, stackTrace) { + logger.e('Error uploading thumbnail', e, stackTrace); emit(ThumbnailCreationError()); } }); diff --git a/lib/entities/file_entity.dart b/lib/entities/file_entity.dart index f3406e3496..750ebee666 100644 --- a/lib/entities/file_entity.dart +++ b/lib/entities/file_entity.dart @@ -45,6 +45,9 @@ class FileEntity extends EntityWithCustomMetadata { @JsonKey(includeFromJson: true, includeToJson: true) Thumbnail? thumbnail; + @JsonKey(includeIfNull: false, name: 'fallbackTxId') + String? fallbackTxId; + @override @JsonKey(includeFromJson: false, includeToJson: false) List reservedGqlTags = [ @@ -79,6 +82,7 @@ class FileEntity extends EntityWithCustomMetadata { this.isHidden, this.thumbnail, this.assignedNames, + this.fallbackTxId, }) : super(ArDriveCrypto()); FileEntity.withUserProvidedDetails({ diff --git a/lib/entities/manifest_data.dart b/lib/entities/manifest_data.dart index b70b475b71..38f84da29b 100644 --- a/lib/entities/manifest_data.dart +++ b/lib/entities/manifest_data.dart @@ -4,6 +4,7 @@ import 'package:ardrive/core/arfs/repository/file_repository.dart'; import 'package:ardrive/core/arfs/repository/folder_repository.dart'; import 'package:ardrive/entities/entities.dart'; import 'package:ardrive/models/models.dart'; +import 'package:ardrive/utils/logger.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; import 'package:collection/collection.dart'; @@ -43,12 +44,26 @@ class ManifestPath { Map toJson() => _$ManifestPathToJson(this); } +@JsonSerializable() +class ManifestFallback { + final String id; + + ManifestFallback(this.id); + + factory ManifestFallback.fromJson(Map json) => + ManifestFallback(json['fallback']['id'] as String); + + Map toJson() => {'id': id}; +} + @JsonSerializable(explicitToJson: true) class ManifestData { @JsonKey() String manifest = 'arweave/paths'; @JsonKey() - String version = '0.1.0'; + String version = '0.2.0'; + @JsonKey(includeIfNull: false) + final ManifestFallback? fallback; @JsonKey() final ManifestIndex index; @JsonKey() @@ -56,8 +71,9 @@ class ManifestData { ManifestData( this.index, - this.paths, - ); + this.paths, { + this.fallback, + }); int get size => jsonData.lengthInBytes; Uint8List get jsonData => utf8.encode(json.encode(this)); @@ -65,6 +81,8 @@ class ManifestData { Future asPreparedDataItem({ required ArweaveAddressString owner, }) async { + logger.d(json.encode(this)); + final manifestDataItem = DataItem.withBlobData(data: jsonData) ..setOwner(owner) ..addApplicationTags( @@ -84,7 +102,7 @@ class ManifestData { /// replace spaces with underscores for arweave.net URL compatibility String prepareManifestPath({ required String filePath, - required String rootFolderPath, + required String rootFolderPath, }) { return filePath.substring(rootFolderPath.length + 1).replaceAll(' ', '_'); } @@ -100,6 +118,7 @@ class ManifestDataBuilder { Future build({ required FolderNode folderNode, + String? fallbackTxId, }) async { final fileList = folderNode .getRecursiveFiles() @@ -141,9 +160,13 @@ class ManifestDataBuilder { ): ManifestPath(file.dataTxId, fileId: file.id) }; + final fallback = + fallbackTxId != null ? ManifestFallback(fallbackTxId) : null; + return ManifestData( index, paths, + fallback: fallback, ); } } diff --git a/lib/manifest/domain/manifest_repository.dart b/lib/manifest/domain/manifest_repository.dart index 18dea707bb..3f38f24fc0 100644 --- a/lib/manifest/domain/manifest_repository.dart +++ b/lib/manifest/domain/manifest_repository.dart @@ -36,6 +36,7 @@ abstract class ManifestRepository { required String manifestName, required FolderNode rootFolderNode, required String driveId, + String? fallbackTxId, }); Future hasPendingFilesOnTargetFolder({required FolderNode folderNode}); @@ -90,6 +91,7 @@ class ManifestRepositoryImpl implements ManifestRepository { dataContentType: ContentType.manifest, assignedNames: manifest.assignedName != null ? [manifest.assignedName!] : null, + fallbackTxId: manifest.fallbackTxId, ); manifestFileEntity.txId = manifest.metadataTxId!; @@ -132,6 +134,7 @@ class ManifestRepositoryImpl implements ManifestRepository { privacy: DrivePrivacyTag.public, assignedName: undername != null ? getLiteralARNSRecordName(undername) : null, + fallbackTxId: params.fallbackTxId, ), wallet: params.wallet, type: params.uploadType, @@ -192,6 +195,7 @@ class ManifestRepositoryImpl implements ManifestRepository { required String manifestName, required FolderNode rootFolderNode, required String driveId, + String? fallbackTxId, }) async { try { final folderNode = rootFolderNode.searchForFolder(parentFolder.id) ?? @@ -199,6 +203,7 @@ class ManifestRepositoryImpl implements ManifestRepository { final arweaveManifest = await _builder.build( folderNode: folderNode, + fallbackTxId: fallbackTxId, ); final manifestFile = await IOFileAdapter().fromData( @@ -304,6 +309,7 @@ class ManifestUploadParams { final String? existingManifestFileId; final UploadType uploadType; final Wallet wallet; + final String? fallbackTxId; ManifestUploadParams({ required this.manifestFile, @@ -312,5 +318,6 @@ class ManifestUploadParams { required this.uploadType, this.existingManifestFileId, required this.wallet, + this.fallbackTxId, }); } diff --git a/lib/models/daos/drive_dao/drive_dao.dart b/lib/models/daos/drive_dao/drive_dao.dart index c27dc8a1db..7cdbb69899 100644 --- a/lib/models/daos/drive_dao/drive_dao.dart +++ b/lib/models/daos/drive_dao/drive_dao.dart @@ -661,6 +661,7 @@ class DriveDao extends DatabaseAccessor with _$DriveDaoMixin { ? Value(jsonEncode(entity.thumbnail!.toJson())) : const Value(null), assignedNames: Value(_encodeAssignedNames(entity.assignedNames)), + fallbackTxId: Value(entity.fallbackTxId), ); return into(fileEntries).insert( diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index fbd711328b..ae374e2017 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -30,7 +30,7 @@ class Database extends _$Database { Database([QueryExecutor? e]) : super(e ?? openConnection()); @override - int get schemaVersion => 23; + int get schemaVersion => 24; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (Migrator m) { @@ -148,6 +148,12 @@ class Database extends _$Database { await m.addColumn(drives, drives.isHidden); await m.addColumn(driveRevisions, driveRevisions.isHidden); } + if (from < 24) { + logger.d('Migrating schema from v23 to v24'); + + await m.addColumn(fileEntries, fileEntries.fallbackTxId); + await m.addColumn(fileRevisions, fileRevisions.fallbackTxId); + } } catch (e, stacktrace) { logger.e( 'CRITICAL! Failed to migrate database from $from to $to', diff --git a/lib/models/file_entry.dart b/lib/models/file_entry.dart index 5483384639..c8b40dd63d 100644 --- a/lib/models/file_entry.dart +++ b/lib/models/file_entry.dart @@ -27,6 +27,7 @@ extension FileEntryExtensions on FileEntry { .map((e) => e.toString()) .toList() : null, + fallbackTxId: fallbackTxId, ); file.customJsonMetadata = parseCustomJsonMetadata(customJsonMetadata); diff --git a/lib/models/file_revision.dart b/lib/models/file_revision.dart index 26b259dec2..eed2d22d1e 100644 --- a/lib/models/file_revision.dart +++ b/lib/models/file_revision.dart @@ -30,6 +30,7 @@ extension FileRevisionsCompanionExtensions on FileRevisionsCompanion { path: '', thumbnail: Value(thumbnail.value), assignedNames: Value(assignedNames.value), + fallbackTxId: fallbackTxId, ); /// Returns a list of [NetworkTransactionsCompanion] representing the metadata and data transactions @@ -81,6 +82,7 @@ extension FileEntityExtensions on FileEntity { isHidden: Value(isHidden ?? false), thumbnail: Value(thumbnailData), assignedNames: Value(assignedNamesData), + fallbackTxId: Value(fallbackTxId), ); } @@ -127,6 +129,9 @@ extension FileEntityExtensions on FileEntity { [FileRevisionsCompanion? previousRevision]) { if (previousRevision == null) { return RevisionAction.create; + } else if (fallbackTxId != null && + fallbackTxId != previousRevision.fallbackTxId.value) { + return RevisionAction.uploadNewVersion; } else if (name != previousRevision.name.value) { return RevisionAction.rename; } else if (parentFolderId != previousRevision.parentFolderId.value) { diff --git a/lib/models/tables/file_entries.drift b/lib/models/tables/file_entries.drift index 57f0b0d3f1..cc1034d3e8 100644 --- a/lib/models/tables/file_entries.drift +++ b/lib/models/tables/file_entries.drift @@ -30,5 +30,7 @@ CREATE TABLE file_entries ( assignedNames TEXT, + fallbackTxId TEXT, + PRIMARY KEY (id, driveId) ) AS FileEntry; diff --git a/lib/models/tables/file_revisions.drift b/lib/models/tables/file_revisions.drift index 856a048193..38aef6c134 100644 --- a/lib/models/tables/file_revisions.drift +++ b/lib/models/tables/file_revisions.drift @@ -32,6 +32,8 @@ CREATE TABLE file_revisions ( assignedNames TEXT, + fallbackTxId TEXT, + PRIMARY KEY (fileId, driveId, dateCreated), FOREIGN KEY (licenseTxId) REFERENCES network_transactions(id), FOREIGN KEY (metadataTxId) REFERENCES network_transactions(id), diff --git a/lib/pages/drive_detail/models/data_table_item.dart b/lib/pages/drive_detail/models/data_table_item.dart index 2bf774e7a2..1fa969de9f 100644 --- a/lib/pages/drive_detail/models/data_table_item.dart +++ b/lib/pages/drive_detail/models/data_table_item.dart @@ -89,7 +89,7 @@ class FileDataTableItem extends ArDriveDataTableItem { final String? pinnedDataOwnerAddress; final Thumbnail? thumbnail; final List? assignedNames; - + final String? fallbackTxId; FileDataTableItem( {required super.driveId, required super.lastUpdated, @@ -108,6 +108,7 @@ class FileDataTableItem extends ArDriveDataTableItem { required this.metadataTx, required this.dataTx, required this.pinnedDataOwnerAddress, + this.fallbackTxId, this.assignedNames, this.thumbnail, super.licenseType, @@ -158,6 +159,7 @@ class DriveDataTableItemMapper { thumbnail: file.thumbnail != null && file.thumbnail != 'null' ? Thumbnail.fromJson(jsonDecode(file.thumbnail!)) : null, + fallbackTxId: file.fallbackTxId, ); } @@ -188,10 +190,11 @@ class DriveDataTableItemMapper { thumbnail: fileEntry.thumbnail != null && fileEntry.thumbnail != 'null' ? Thumbnail.fromJson(jsonDecode(fileEntry.thumbnail!)) : null, + fallbackTxId: fileEntry.fallbackTxId, ); } -static FolderDataTableItem fromFolderEntry( + static FolderDataTableItem fromFolderEntry( FolderEntry folderEntry, int index, bool isOwner, @@ -256,6 +259,7 @@ static FolderDataTableItem fromFolderEntry( thumbnail: revision.thumbnail != null && revision.thumbnail != 'null' ? Thumbnail.fromJson(jsonDecode(revision.thumbnail!)) : null, + fallbackTxId: revision.fallbackTxId, ); } } diff --git a/packages/ardrive_ui/lib/src/components/modal.dart b/packages/ardrive_ui/lib/src/components/modal.dart index a323ab1230..28bf199ede 100644 --- a/packages/ardrive_ui/lib/src/components/modal.dart +++ b/packages/ardrive_ui/lib/src/components/modal.dart @@ -88,6 +88,7 @@ class ArDriveModalNew extends StatelessWidget { text: action!.title, onPressed: action!.action, typography: ArDriveTypographyNew.of(context), + isDisabled: !action!.isEnable, ), ), ], diff --git a/packages/ardrive_uploader/lib/src/arfs_upload_metadata.dart b/packages/ardrive_uploader/lib/src/arfs_upload_metadata.dart index 08c6fe514e..fbab2d0b6d 100644 --- a/packages/ardrive_uploader/lib/src/arfs_upload_metadata.dart +++ b/packages/ardrive_uploader/lib/src/arfs_upload_metadata.dart @@ -170,6 +170,7 @@ class ARFSFileUploadMetadata extends ARFSUploadMetadata with ARFSUploadData { required super.id, required super.isPrivate, this.assignedName, + this.fallbackTxId, }); /// The size of the file in bytes. @@ -207,6 +208,8 @@ class ARFSFileUploadMetadata extends ARFSUploadMetadata with ARFSUploadData { String? assignedName; + final String? fallbackTxId; + // Public method to set licenseTxId with validation or additional logic void updateLicenseTxId(String licenseTxId) { _licenseTxId = licenseTxId; @@ -236,6 +239,7 @@ class ARFSFileUploadMetadata extends ARFSUploadMetadata with ARFSUploadData { }, if (licenseTxId != null) 'licenseTxId': licenseTxId, if (assignedName != null) 'assignedNames': [assignedName!], + if (fallbackTxId != null) 'fallbackTxId': fallbackTxId, }; } } diff --git a/packages/ardrive_uploader/lib/src/exceptions.dart b/packages/ardrive_uploader/lib/src/exceptions.dart index 6f424058e7..228dee80f6 100644 --- a/packages/ardrive_uploader/lib/src/exceptions.dart +++ b/packages/ardrive_uploader/lib/src/exceptions.dart @@ -1,4 +1,4 @@ -abstract class ArDriveUploaderExceptions { +abstract class ArDriveUploaderExceptions implements Exception { abstract final String message; abstract final Object? error; } @@ -140,3 +140,15 @@ class ThumbnailUploadException implements UploadStrategyException { @override Object? error; } + +class TurboUploadTimeoutException implements ArDriveUploaderExceptions { + TurboUploadTimeoutException({ + this.message = 'Turbo upload timeout', + this.error, + }); + + @override + final String message; + @override + Object? error; +} diff --git a/packages/ardrive_uploader/lib/src/factories.dart b/packages/ardrive_uploader/lib/src/factories.dart index 77750631a3..c1ce19e7c5 100644 --- a/packages/ardrive_uploader/lib/src/factories.dart +++ b/packages/ardrive_uploader/lib/src/factories.dart @@ -5,6 +5,8 @@ import 'package:ardrive_uploader/src/data_bundler.dart'; import 'package:ardrive_uploader/src/streamed_upload.dart'; import 'package:ardrive_uploader/src/turbo_streamed_upload.dart'; import 'package:ardrive_uploader/src/turbo_upload_service.dart'; +import 'package:ardrive_uploader/src/utils/logger.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; import 'package:pst/pst.dart'; @@ -101,15 +103,34 @@ class StreamedUploadFactory { required this.turboUploadUri, }); - StreamedUpload fromUploadType( - UploadType type, - ) { - if (type == UploadType.d2n) { + Future fromUploadType( + UploadTask task, + ) async { + if (task.type == UploadType.d2n) { return D2NStreamedUpload(); - } else if (type == UploadType.turbo) { + } else if (task.type == UploadType.turbo) { + bool useMultipart; + + if (task is FileUploadTask) { + final fileSize = await (task).file.length; + + if (fileSize >= MiB(5).size) { + useMultipart = true; + } else { + useMultipart = false; + } + } else { + /// Non-file uploads e.g. folder uploads (bundle of folders metadata) + /// are always chunked + useMultipart = false; + } + + logger.i('useMultipart: $useMultipart'); + return TurboStreamedUpload( - TurboUploadService( - turboUploadUri: turboUploadUri, + TurboUploadServiceFactory().createTurboUploadService( + useMultipart, + turboUploadUri, ), ); } else { @@ -117,3 +138,14 @@ class StreamedUploadFactory { } } } + +class TurboUploadServiceFactory { + TurboUploadService createTurboUploadService( + bool isMultipart, Uri turboUploadUri) { + if (isMultipart) { + return TurboUploadServiceMultipart(turboUploadUri: turboUploadUri); + } else { + return TurboUploadServiceNonChunked(turboUploadUri); + } + } +} diff --git a/packages/ardrive_uploader/lib/src/metadata_generator.dart b/packages/ardrive_uploader/lib/src/metadata_generator.dart index 61e0ef1716..421af241b4 100644 --- a/packages/ardrive_uploader/lib/src/metadata_generator.dart +++ b/packages/ardrive_uploader/lib/src/metadata_generator.dart @@ -98,6 +98,7 @@ class ARFSUploadMetadataGenerator licenseDefinitionTxId: arguments.licenseDefinitionTxId, licenseAdditionalTags: arguments.licenseAdditionalTags, assignedName: arguments.assignedName, + fallbackTxId: arguments.fallbackTxId, ) ..setDataTags(tags['data-item']!) ..setEntityMetadataTags(tags['entity']!); @@ -174,6 +175,7 @@ class ARFSUploadMetadataArgs extends Equatable { final String? licenseDefinitionTxId; final Map? licenseAdditionalTags; final String? assignedName; + final String? fallbackTxId; factory ARFSUploadMetadataArgs.file({ required String driveId, @@ -183,6 +185,7 @@ class ARFSUploadMetadataArgs extends Equatable { String? entityId, Map? customBundleTags, String? assignedName, + String? fallbackTxId, }) { return ARFSUploadMetadataArgs( driveId: driveId, @@ -191,6 +194,7 @@ class ARFSUploadMetadataArgs extends Equatable { entityId: entityId, type: type, assignedName: assignedName, + fallbackTxId: fallbackTxId, ); } @@ -231,6 +235,7 @@ class ARFSUploadMetadataArgs extends Equatable { this.licenseDefinitionTxId, this.licenseAdditionalTags, this.assignedName, + this.fallbackTxId, }); @override diff --git a/packages/ardrive_uploader/lib/src/turbo_upload_service.dart b/packages/ardrive_uploader/lib/src/turbo_upload_service.dart index ade0616674..b26cda7850 100644 --- a/packages/ardrive_uploader/lib/src/turbo_upload_service.dart +++ b/packages/ardrive_uploader/lib/src/turbo_upload_service.dart @@ -2,184 +2,315 @@ import 'dart:async'; import 'dart:math'; import 'dart:typed_data'; +import 'package:arconnect/arconnect.dart'; import 'package:ardrive_uploader/src/exceptions.dart'; import 'package:ardrive_uploader/src/utils/logger.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; import 'package:dio/dio.dart'; import 'package:retry/retry.dart'; +import 'package:uuid/uuid.dart'; -class TurboUploadService { - TurboUploadService({ - required this.turboUploadUri, +abstract class TurboUploadService { + Future post({ + required DataItemResult dataItem, + required Wallet wallet, + Function(double)? onSendProgress, + Map? headers, }); + Future cancel(); +} + +abstract class TurboUploadServiceChunkUploadsBase + implements TurboUploadService { + TurboUploadServiceChunkUploadsBase(this.turboUploadUri); + final Uri turboUploadUri; final r = RetryOptions(maxAttempts: 8); final List _cancelTokens = []; final dio = Dio(); - final maxInFlightData = MiB(100).size; Timer? onSendProgressTimer; + bool _isCanceled = false; + @override Future post({ required DataItemResult dataItem, required Wallet wallet, Function(double)? onSendProgress, Map? headers, }) async { - logger.d('[${dataItem.id}] Uploading DataItem to Turbo'); + logger.d('[${dataItem.id}] Starting upload...'); + // 1) Fetch basic upload info final uploadInfo = await r.retry( () => dio.get('$turboUploadUri/chunks/arweave/-1/-1'), ); - final uploadId = uploadInfo.data['id'] as String; - final uploadChunkSizeMinInBytes = uploadInfo.data['min'] as int; - final uploadChunkSizeMaxInBytes = uploadInfo.data['max'] as int; - final uploadChunkSizeInBytes = _calculateChunkSize( + final minChunkSize = uploadInfo.data['min'] as int; + final maxChunkSize = uploadInfo.data['max'] as int; + + // 2) Calculate chunk size + concurrency + final chunkSize = _calculateChunkSize( dataSize: dataItem.dataItemSize, - minChunkSize: uploadChunkSizeMinInBytes, - maxChunkSize: uploadChunkSizeMaxInBytes, + minChunkSize: minChunkSize, + maxChunkSize: maxChunkSize, + ); + final maxInFlightData = MiB(100).size; + final maxUploadsInParallel = maxInFlightData ~/ chunkSize; + + logger.d( + '[${dataItem.id}] UploadID=$uploadId, chunkSize=$chunkSize, parallel=$maxUploadsInParallel', ); - final maxUploadsInParallel = maxInFlightData ~/ uploadChunkSizeInBytes; - logger.i( - '[${dataItem.id}] Upload ID: $uploadId, Uploads in parallel: $maxUploadsInParallel, Chunk size: $uploadChunkSizeInBytes'); - // (offset: sent bytes) map for in flight requests progress + // Setup for progress tracking Map inFlightRequestsBytesSent = {}; int completedRequestsBytesSent = 0; - if (onSendProgress != null) { - onSendProgressTimer = - Timer.periodic(Duration(milliseconds: 500), (timer) { - final inFlightBytesSent = inFlightRequestsBytesSent.isEmpty - ? 0 - : inFlightRequestsBytesSent.values.reduce((a, b) => a + b); - final totalBytesSent = completedRequestsBytesSent + inFlightBytesSent; + onSendProgressTimer = Timer.periodic(Duration(milliseconds: 500), (_) { + if (inFlightRequestsBytesSent.isEmpty) return; + + final inFlightSum = + inFlightRequestsBytesSent.values.fold(0, (a, b) => a + b); + final totalBytesSent = completedRequestsBytesSent + inFlightSum; final progress = totalBytesSent / dataItem.dataItemSize; + onSendProgress(progress); - if (progress >= 1) { - timer.cancel(); + // If we reached 100%, cancel the timer + if (progress >= 1.0) { + onSendProgressTimer?.cancel(); } - - onSendProgress(totalBytesSent / dataItem.dataItemSize); }); } + // 3) Stream and upload chunks concurrently await _processStream( - stream: dataItem.streamGenerator(), - chunkSize: uploadChunkSizeInBytes, - maxConcurrent: maxUploadsInParallel, - dataItemId: dataItem.id, (chunk, offset) async { - if (_isCanceled) { - throw UploadCanceledException('Upload canceled. Cant upload chunk.'); - } - - final cancelToken = CancelToken(); - - _cancelTokens.add(cancelToken); + stream: dataItem.streamGenerator(), + chunkSize: chunkSize, + maxConcurrent: maxUploadsInParallel, + dataItemId: dataItem.id, + processChunk: (chunk, offset) async { + if (_isCanceled) { + throw UploadCanceledException('Upload canceled'); + } - try { - logger.d('[${dataItem.id}] Uploading chunk. Offset: $offset'); - return r.retry(() { - return dio.post( - '$turboUploadUri/chunks/arweave/$uploadId/$offset', - data: chunk, - onSendProgress: (sent, total) { + final cancelToken = CancelToken(); + _cancelTokens.add(cancelToken); + + try { + final response = await r + .retry(() => _uploadChunkRequest( + uploadId: uploadId, + chunk: chunk, + offset: offset, + headers: headers ?? const {}, + onSendProgress: (sent) { + if (onSendProgress == null) return; + inFlightRequestsBytesSent[offset] = + max(inFlightRequestsBytesSent[offset] ?? 0, sent); + }, + cancelToken: cancelToken, + )) + .then( + (response) { + // On success + _cancelTokens.remove(cancelToken); if (onSendProgress != null) { - if (inFlightRequestsBytesSent[offset] == null) { - inFlightRequestsBytesSent[offset] = 0; - } else if (inFlightRequestsBytesSent[offset]! < sent) { - inFlightRequestsBytesSent[offset] = sent; - } + // Once chunk is fully uploaded, move to "completed" + final uploadedThisChunk = + inFlightRequestsBytesSent[offset] ?? chunk.length; + completedRequestsBytesSent += uploadedThisChunk; + inFlightRequestsBytesSent.remove(offset); } + return response; + }, + onError: (err) { + // On error + onSendProgressTimer?.cancel(); + _cancelTokens.remove(cancelToken); + throw err; }, - options: Options( - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Length': chunk.length.toString(), - }..addAll(headers ?? const {}), - ), - cancelToken: cancelToken, ); - }).then((response) { - _cancelTokens.remove(cancelToken); - - if (onSendProgress != null) { - inFlightRequestsBytesSent.remove(offset); - completedRequestsBytesSent += chunk.length; - } return response; - }, onError: (error) { - onSendProgressTimer?.cancel(); + } catch (err) { + if (_isCanceled) { + cancelToken.cancel(); + onSendProgressTimer?.cancel(); + } _cancelTokens.remove(cancelToken); - - throw error; - }); - } catch (e) { - if (_isCanceled) { - logger.i('[${dataItem.id}] Upload canceled'); - onSendProgressTimer?.cancel(); - cancelToken.cancel(); + rethrow; } + }, + ); - _cancelTokens.remove(cancelToken); + // 4) Finalize upload + try { + logger.d('[${dataItem.id}] Finalizing upload: $uploadId'); + final finalizeResponse = await finalizeUpload( + uploadId: uploadId, + dataItemSize: dataItem.dataItemSize, + dataItemId: dataItem.id, + ); + onSendProgressTimer?.cancel(); + return finalizeResponse; + } catch (err) { + onSendProgressTimer?.cancel(); + rethrow; + } + } - rethrow; - } - }); + Future finalizeUpload({ + required String uploadId, + required int dataItemSize, + required TxID dataItemId, + }); - final finalizeCancelToken = CancelToken(); + Future _uploadChunkRequest({ + required String uploadId, + required Uint8List chunk, + required int offset, + required Map headers, + required Function(int) onSendProgress, + required CancelToken cancelToken, + }); - try { - logger.i('[${dataItem.id}] Finalising upload to Turbo'); + @override + Future cancel() async { + _isCanceled = true; + onSendProgressTimer?.cancel(); + for (final token in _cancelTokens) { + token.cancel(); + } + logger.d('Upload canceled.'); + } +} - _cancelTokens.add(finalizeCancelToken); +/// A small helper to process a stream in fixed-size chunks concurrently. +Future _processStream({ + required Future Function(Uint8List, int) processChunk, + required Stream stream, + required int chunkSize, + required int maxConcurrent, + required TxID dataItemId, +}) async { + logger.d('[$dataItemId] Processing DataItem stream'); + final chunkedStream = streamToChunks(stream, chunkSize); + + final runningTasks = []; + int offset = 0; + + await for (final chunk in chunkedStream) { + if (runningTasks.length >= maxConcurrent) { + await Future.any(runningTasks); + } - final finaliseInfo = await r.retry( - () => dio.post( - '$turboUploadUri/chunks/arweave/$uploadId/finalize', - data: null, - cancelToken: finalizeCancelToken, - ), - ); + final task = processChunk(chunk, offset); + runningTasks.add(task); + task.whenComplete(() => runningTasks.remove(task)); - if (finaliseInfo.statusCode == 202) { - // TODO: Send this upload to a queue. We'd need to change the - // type of the returned data though. Perhaps the returned object - // could be an event emitter that the calling client can use to - // listen for async outcomes like finalization success/failure. - final confirmInfo = await _confirmUpload( - dataItemId: dataItem.id, - uploadId: uploadId, - dataItemSize: dataItem.dataItemSize, - ); + offset += chunk.length; + } - onSendProgressTimer?.cancel(); + await Future.wait(runningTasks); + logger.d('[$dataItemId] All chunks uploaded'); +} - return confirmInfo; - } +/// Converts the stream into fixed-size chunks +Stream streamToChunks( + Stream stream, int chunkSize) async* { + final buffer = BytesBuilder(); + await for (final data in stream) { + buffer.add(data); + while (buffer.length >= chunkSize) { + final currentBytes = buffer.takeBytes(); + yield Uint8List.fromList(currentBytes.sublist(0, chunkSize)); + buffer.add(currentBytes.sublist(chunkSize)); + } + } + if (buffer.isNotEmpty) { + yield buffer.toBytes(); + } +} - logger.i('[${dataItem.id}] Upload finalised'); +/// Calculates a valid chunk size based on the total data size +int _calculateChunkSize({ + required int dataSize, + required int minChunkSize, + required int maxChunkSize, +}) { + int applyLimits(int c) => + c < minChunkSize ? minChunkSize : (c > maxChunkSize ? maxChunkSize : c); + + if (dataSize < GiB(1).size) { + return applyLimits(MiB(5).size); + } else if (dataSize <= GiB(2).size) { + return applyLimits(MiB(25).size); + } else { + return applyLimits(MiB(50).size); + } +} - onSendProgressTimer?.cancel(); +class TurboUploadServiceMultipart extends TurboUploadServiceChunkUploadsBase { + TurboUploadServiceMultipart({required Uri turboUploadUri}) + : super(turboUploadUri); - return finaliseInfo; - } catch (e) { - if (e is DioException) { - logger.i('[${dataItem.id}] Finalising upload failed, ${e.type}'); - } else if (_isCanceled) { - logger.i('[${dataItem.id}] Upload canceled'); - finalizeCancelToken.cancel(); - } + @override + Future finalizeUpload({ + required String uploadId, + required int dataItemSize, + required TxID dataItemId, + }) async { + try { + // POST /finalize + final finalizeResponse = await r.retry( + () => dio.post('$turboUploadUri/chunks/arweave/$uploadId/finalize'), + ); - onSendProgressTimer?.cancel(); + // If the server returns 202 (still assembling/finalizing), you could poll + // for status, like in your original _confirmUpload method. For brevity, + // you might just return it here or do something like: + if (finalizeResponse.statusCode == 202) { + logger.i('Still finalizing. Checking status...'); + return _confirmUpload( + dataItemId: dataItemId, + uploadId: uploadId, + dataItemSize: dataItemSize, + ); + } + logger.i('Multipart finalize complete.'); + return finalizeResponse; + } catch (e) { rethrow; } } + @override + Future _uploadChunkRequest({ + required String uploadId, + required Uint8List chunk, + required int offset, + required Map headers, + required Function(int) onSendProgress, + required CancelToken cancelToken, + }) async { + // POST /{uploadId}/{offset} + return dio.post( + '$turboUploadUri/chunks/arweave/$uploadId/$offset', + data: chunk, + onSendProgress: (sent, total) => onSendProgress(sent.toInt()), + options: Options( + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': chunk.length.toString(), + ...headers, + }, + ), + cancelToken: cancelToken, + ); + } + // TODO: This funciton as designed should go away, but some incremental // improvements that could be helpful: // - Don't use recursion. Use a while loop instead. @@ -233,113 +364,120 @@ class TurboUploadService { 'Upload canceled. Finalization took too long.'); } - Future cancel() { - logger.d('Stream closed'); + Duration dataItemConfirmationRetryDelay( + int iteration, { + Duration baseDuration = const Duration(milliseconds: 100), + Duration maxDuration = const Duration(seconds: 8), + }) { + return Duration( + milliseconds: min( + baseDuration.inMilliseconds * pow(2, iteration).toInt(), + maxDuration.inMilliseconds, + ), + ); + } +} - for (var cancelToken in _cancelTokens) { - cancelToken.cancel(); - } +class TurboUploadServiceNonChunked extends TurboUploadService { + TurboUploadServiceNonChunked(this.turboUploadUri) : super(); - _isCanceled = true; + final Uri turboUploadUri; + final r = RetryOptions(maxAttempts: 8); + final dio = Dio(); + Timer? onSendProgressTimer; + final cancelToken = CancelToken(); + final TabVisibilitySingleton _tabVisibility = TabVisibilitySingleton(); + + @override + Future cancel() { onSendProgressTimer?.cancel(); + cancelToken.cancel(); return Future.value(); } - bool _isCanceled = false; + @override + Future post({ + required DataItemResult dataItem, + required Wallet wallet, + Function(double p1)? onSendProgress, + Map? headers, + }) async { + final controller = StreamController(); - int _calculateChunkSize({ - required int dataSize, - required int minChunkSize, - required int maxChunkSize, - }) { - getValidChunkSize(int chunkSize) { - if (chunkSize < minChunkSize) { - return minChunkSize; - } else if (chunkSize > maxChunkSize) { - return maxChunkSize; - } else { - return chunkSize; - } - } + controller.add(0); + try { + final acceptedStatusCodes = [200, 202, 204]; + + final nonce = const Uuid().v4(); + final publicKey = await safeArConnectAction( + _tabVisibility, + (_) async { + logger.d('Getting public key with safe ArConnect action'); + return wallet.getOwner(); + }, + ); + final signature = await safeArConnectAction( + _tabVisibility, + (_) async { + logger.d('Signing with safe ArConnect action'); + return signNonceAndData( + nonce: nonce, + wallet: wallet, + ); + }, + ); - if (dataSize < GiB(1).size) { - return getValidChunkSize(MiB(5).size); - } else if (dataSize <= GiB(2).size) { - return getValidChunkSize(MiB(25).size); - } else { - return getValidChunkSize(MiB(50).size); - } - } + final headers = { + 'x-nonce': nonce, + 'x-signature': signature, + 'x-public-key': publicKey, + }; + + final url = '$turboUploadUri/v1/tx'; + const receiveTimeout = Duration(days: 365); + const sendTimeout = Duration(days: 365); + + final response = await dio.post( + url, + onSendProgress: (sent, total) => onSendProgress?.call(sent / total), + data: dataItem.streamGenerator(), + options: Options( + headers: headers, + receiveTimeout: receiveTimeout, + sendTimeout: sendTimeout, + ), + cancelToken: cancelToken, + ); - Future _processStream( - Future Function(Uint8List, int) processChunk, { - required Stream stream, - required int chunkSize, - required int maxConcurrent, - required TxID dataItemId, - }) async { - logger.d('[$dataItemId] Processing DataItem stream'); - final chunkedStream = streamToChunks(stream, chunkSize); - logger.d('[$dataItemId] Stream chunked'); - final runningTasks = []; - int offset = 0; - - await for (final chunk in chunkedStream) { - if (runningTasks.length >= maxConcurrent) { - logger.d('[$dataItemId] Waiting for a task to finish'); - await Future.any(runningTasks); + if (!acceptedStatusCodes.contains(response.statusCode)) { + logger.e('Error posting bytes', response.data); + throw Exception('Error posting bytes'); } - logger.d('[$dataItemId] Starting new task. Offset: $offset'); - final task = processChunk(chunk, offset); - task.whenComplete(() { - logger.d('[$dataItemId] Task completed. Offset: $offset'); - runningTasks.remove(task); - }); - - runningTasks.add(task); + if (!acceptedStatusCodes.contains(response.statusCode)) { + logger.e('Error posting bytes', response.data); + throw _handleException(response); + } - offset += chunk.length; + return response; + } catch (e, stacktrace) { + logger.e('Catching error in postDataItem', e, stacktrace); + throw _handleException(e); } - - logger.d('[$dataItemId] Waiting for all tasks to finish'); - await Future.wait(runningTasks); } -} -Stream streamToChunks( - Stream stream, - int chunkSize, -) async* { - var buffer = BytesBuilder(); + Exception _handleException(Object error) { + logger.e('Handling exception in UploadService', error); - await for (var uint8list in stream) { - buffer.add(uint8list); - - while (buffer.length >= chunkSize) { - final currentBytes = buffer.takeBytes(); - yield Uint8List.fromList(currentBytes.sublist(0, chunkSize)); + if (error is DioException && error.response?.statusCode == 408) { + logger.e( + 'Handling exception in UploadService with status code: ${error.response?.statusCode}', + error, + ); - buffer.add(currentBytes.sublist(chunkSize)); + return TurboUploadTimeoutException(); } - } - if (buffer.length > 0) { - yield buffer.toBytes(); + return Exception(error); } } - -final defaultBaseDuration = Duration(milliseconds: 250); - -Duration dataItemConfirmationRetryDelay( - int iteration, { - Duration baseDuration = const Duration(milliseconds: 100), - Duration maxDuration = const Duration(seconds: 8), -}) { - return Duration( - milliseconds: min( - baseDuration.inMilliseconds * pow(2, iteration).toInt(), - maxDuration.inMilliseconds, - ), - ); -} diff --git a/packages/ardrive_uploader/lib/src/upload_strategy.dart b/packages/ardrive_uploader/lib/src/upload_strategy.dart index fd4f94b056..623ba98650 100644 --- a/packages/ardrive_uploader/lib/src/upload_strategy.dart +++ b/packages/ardrive_uploader/lib/src/upload_strategy.dart @@ -86,7 +86,7 @@ class UploadFileUsingDataItemFiles extends UploadFileStrategy { } final metadataStreamedUpload = - _streamedUploadFactory.fromUploadType(task.type); + await _streamedUploadFactory.fromUploadType(task); final uploadResult = await metadataStreamedUpload.send( DataItemUploadItem( @@ -153,7 +153,7 @@ class UploadFileUsingDataItemFiles extends UploadFileStrategy { ); } - final streamedUpload = _streamedUploadFactory.fromUploadType(task.type); + final streamedUpload = await _streamedUploadFactory.fromUploadType(task); dataItemTask = dataItemTask.copyWith( status: UploadStatus.inProgress, @@ -259,7 +259,7 @@ class UploadFileUsingBundleStrategy extends UploadFileStrategy { throw UploadCanceledException('Upload canceled'); } - final streamedUpload = _streamedUploadFactory.fromUploadType(task.type); + final streamedUpload = await _streamedUploadFactory.fromUploadType(task); task = task.copyWith( status: UploadStatus.inProgress, @@ -351,7 +351,7 @@ class UploadFolderStructureAsBundleStrategy } final streamedUpload = - _streamedUploadFactory.fromUploadType(folderTask.type); + await _streamedUploadFactory.fromUploadType(folderTask); final result = await streamedUpload.send(folderTask.uploadItem!, wallet, (progress) { @@ -407,8 +407,7 @@ class _UploadThumbnailStrategy implements UploadThumbnailStrategy { /// It will always use the Turbo for now - final streamedUpload = - _streamedUploadFactory.fromUploadType(UploadType.turbo); + final streamedUpload = await _streamedUploadFactory.fromUploadType(task); final result = await streamedUpload.send( task.uploadItem!, diff --git a/packages/ardrive_uploader/test/factories_test.dart b/packages/ardrive_uploader/test/factories_test.dart index 0fa8b00640..bf73da094b 100644 --- a/packages/ardrive_uploader/test/factories_test.dart +++ b/packages/ardrive_uploader/test/factories_test.dart @@ -1,7 +1,10 @@ +import 'package:ardrive_io/ardrive_io.dart'; import 'package:ardrive_uploader/ardrive_uploader.dart'; import 'package:ardrive_uploader/src/d2n_streamed_upload.dart'; import 'package:ardrive_uploader/src/data_bundler.dart'; import 'package:ardrive_uploader/src/turbo_streamed_upload.dart'; +import 'package:ardrive_uploader/src/turbo_upload_service.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pst/pst.dart'; @@ -20,6 +23,12 @@ class MockDataBundler extends Mock implements DataBundler {} class MockStreamedUploadFactory extends Mock implements StreamedUploadFactory {} +class MockFileUploadTask extends Mock implements FileUploadTask {} + +class MockFile extends Mock implements IOFile {} + +class MockUploadTask extends Mock implements UploadTask {} + void main() { setUpAll(() { registerFallbackValue(UploadType.turbo); @@ -93,19 +102,50 @@ void main() { uploadFactory = StreamedUploadFactory(turboUploadUri: mockUri); }); - test('should return D2NStreamedUpload for UploadType.d2n', () { - var streamedUpload = uploadFactory.fromUploadType(UploadType.d2n); + test('should return D2NStreamedUpload for UploadType.d2n', () async { + final task = MockFileUploadTask(); + when(() => task.type).thenReturn(UploadType.d2n); + + final streamedUpload = await uploadFactory.fromUploadType(task); expect(streamedUpload, isA()); }); - test('should return TurboStreamedUpload for UploadType.turbo', () { - var streamedUpload = uploadFactory.fromUploadType(UploadType.turbo); - expect(streamedUpload, isA()); - - // Additional check to verify TurboUploadService initialization - var turboUploadUri = - (streamedUpload as TurboStreamedUpload).service.turboUploadUri; - expect(turboUploadUri, equals(mockUri)); + group('TurboStreamedUpload', () { + test('should use multipart for files equal or larger than 5MB', () async { + final task = MockFileUploadTask(); + final file = MockFile(); + when(() => task.type).thenReturn(UploadType.turbo); + when(() => task.file).thenReturn(file); + when(() => file.length).thenAnswer((_) async => MiB(5).size); + + final streamedUpload = await uploadFactory.fromUploadType(task); + expect(streamedUpload, isA()); + expect((streamedUpload as TurboStreamedUpload).service, + isA()); + }); + + test('should use chunked for files smaller than 5MB', () async { + final task = MockFileUploadTask(); + final file = MockFile(); + when(() => task.type).thenReturn(UploadType.turbo); + when(() => task.file).thenReturn(file); + when(() => file.length).thenAnswer((_) async => MiB(4).size); + + final streamedUpload = await uploadFactory.fromUploadType(task); + expect(streamedUpload, isA()); + expect((streamedUpload as TurboStreamedUpload).service, + isA()); + }); + + test('should use multipart for non-file upload tasks', () async { + final task = MockUploadTask(); + when(() => task.type).thenReturn(UploadType.turbo); + + final streamedUpload = await uploadFactory.fromUploadType(task); + expect(streamedUpload, isA()); + expect((streamedUpload as TurboStreamedUpload).service, + isA()); + }); }); }); } diff --git a/packages/ardrive_utils/lib/src/validations.dart b/packages/ardrive_utils/lib/src/validations.dart index 7b3acab1b9..1f971b8a15 100644 --- a/packages/ardrive_utils/lib/src/validations.dart +++ b/packages/ardrive_utils/lib/src/validations.dart @@ -1,3 +1,5 @@ +import 'package:ardrive_utils/ardrive_utils.dart'; + bool isValidUuidV4(String uuid) { final RegExp uuidV4Pattern = RegExp( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'); @@ -11,3 +13,19 @@ bool isValidUuidFormat(String uuid) { return uuidPattern.hasMatch(uuid.toLowerCase()); } + +bool isValidTxId(String txId) { + final RegExp txIdPattern = RegExp(r'^[0-9a-f]{64}$'); + return txIdPattern.hasMatch(txId.toLowerCase()); +} + +bool isValidArweaveTxId(TxID txId) { + // Check if the length of the string is 43 + if (txId.length != 43) { + return false; + } + + // Check if the string contains only base64url valid characters + final base64UrlRegex = RegExp(r'^[A-Za-z0-9_-]+$'); + return base64UrlRegex.hasMatch(txId); +} diff --git a/packages/ardrive_utils/test/src/validations_test.dart b/packages/ardrive_utils/test/src/validations_test.dart index 8a9d807a93..b31831da9a 100644 --- a/packages/ardrive_utils/test/src/validations_test.dart +++ b/packages/ardrive_utils/test/src/validations_test.dart @@ -87,4 +87,45 @@ void main() { expect(isValidUuidFormat(''), isFalse); }); }); + + group('isValidArweaveTxId Tests', () { + test('should return true for valid Arweave transaction IDs', () { + expect(isValidArweaveTxId('mn8q_r4h8i7oZaVDnpusJ6uOGIVH1Ak80ZBhy8sUc7w'), + isTrue); + + expect(isValidArweaveTxId('cQU3_wXscrghGlqmbF5ef-iu9tOdFq2Xuq-anLRIAHA'), + isTrue); + expect(isValidArweaveTxId('2FivxlgSuK9s2GZ0cvAYkQTc0ZrmEZLPhwBmVtY_bVY'), + isTrue); + }); + + test('should return false for invalid length', () { + expect(isValidArweaveTxId('abc'), isFalse); // Too short + expect( + isValidArweaveTxId('_R4bUV8qt7UYsBAGJHmXwKpKP2qJuwWGPOfnQYRXVIw123'), + isFalse); // Too long + expect(isValidArweaveTxId(''), isFalse); // Empty string + }); + + test('should return false for invalid characters', () { + expect(isValidArweaveTxId('_R4bUV8qt7UYsB@GJHmXwKpKP2qJuwWGPOfnQYRXVIw'), + isFalse); // Contains @ + expect(isValidArweaveTxId('_R4bUV8qt7UYsB#GJHmXwKpKP2qJuwWGPOfnQYRXVIw'), + isFalse); // Contains # + expect(isValidArweaveTxId('_R4bUV8qt7UYsB GJHmXwKpKP2qJuwWGPOfnQYRXVIw'), + isFalse); // Contains space + }); + + test('should return false for invalid base64url encoding', () { + // Contains invalid padding character '=' + expect(isValidArweaveTxId('_R4bUV8qt7UYsBAGJHmXwKpKP2qJuwWGPOfnQYRXVI='), + isFalse); + // Contains '/' which is not base64url safe + expect(isValidArweaveTxId('_R4bUV8qt7/YsBAGJHmXwKpKP2qJuwWGPOfnQYRXVIw'), + isFalse); + // Contains '+' which is not base64url safe + expect(isValidArweaveTxId('_R4bUV8qt7+YsBAGJHmXwKpKP2qJuwWGPOfnQYRXVIw'), + isFalse); + }); + }); } diff --git a/pubspec.yaml b/pubspec.yaml index 190312237b..d60149ff55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Secure, permanent storage publish_to: 'none' -version: 2.61.0 +version: 2.62.0 environment: sdk: '>=3.2.0 <4.0.0' diff --git a/test/entities/expected_manifest_data.dart b/test/entities/expected_manifest_data.dart index 82ec2689fb..884f0e3815 100644 --- a/test/entities/expected_manifest_data.dart +++ b/test/entities/expected_manifest_data.dart @@ -1,4 +1,4 @@ -const expectedManifestData = [ +const expectedManifestDataVersion020 = [ 123, 34, 109, @@ -40,12 +40,648 @@ const expectedManifestData = [ 34, 48, 46, + 50, + 46, + 48, + 34, + 44, + 34, + 105, + 110, + 100, + 101, + 120, + 34, + 58, + 123, + 34, + 112, + 97, + 116, + 104, + 34, + 58, + 34, + 102, + 105, + 108, + 101, + 45, + 105, + 110, + 45, + 114, + 111, + 111, + 116, + 45, + 49, + 34, + 125, + 44, + 34, + 112, + 97, + 116, + 104, + 115, + 34, + 58, + 123, + 34, + 102, + 105, + 108, + 101, + 45, + 105, + 110, + 45, + 114, + 111, + 111, + 116, + 45, + 49, + 34, + 58, + 123, + 34, + 105, + 100, + 34, + 58, + 34, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 49, + 34, + 125, + 44, + 34, + 102, + 105, + 108, + 101, + 45, + 105, + 110, + 45, + 114, + 111, + 111, + 116, + 45, + 50, + 34, + 58, + 123, + 34, + 105, + 100, + 34, + 58, + 34, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 49, + 34, + 125, + 44, + 34, + 112, + 97, + 114, + 101, + 110, + 116, + 45, + 102, + 111, + 108, + 100, + 101, + 114, + 47, + 102, + 105, + 108, + 101, + 45, + 105, + 110, + 45, + 112, + 97, + 114, + 101, + 110, + 116, + 45, + 49, + 34, + 58, + 123, + 34, + 105, + 100, + 34, + 58, + 34, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 49, + 34, + 125, + 44, + 34, + 112, + 97, + 114, + 101, + 110, + 116, + 45, + 102, + 111, + 108, + 100, + 101, + 114, + 47, + 102, + 105, + 108, + 101, + 45, + 105, + 110, + 45, + 112, + 97, + 114, + 101, + 110, + 116, + 45, + 50, + 34, + 58, + 123, + 34, + 105, + 100, + 34, + 58, + 34, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 49, + 34, + 125, + 44, + 34, + 112, + 97, + 114, + 101, + 110, + 116, + 45, + 102, + 111, + 108, + 100, + 101, + 114, + 47, + 99, + 104, + 105, + 108, + 100, + 45, + 102, + 111, + 108, + 100, + 101, + 114, + 47, + 102, + 105, + 108, + 101, + 45, + 105, + 110, + 45, + 99, + 104, + 105, + 108, + 100, + 45, + 49, + 34, + 58, + 123, + 34, + 105, + 100, + 34, + 58, + 34, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 49, + 34, + 125, + 44, + 34, + 112, + 97, + 114, + 101, + 110, + 116, + 45, + 102, + 111, + 108, + 100, + 101, + 114, + 47, + 99, + 104, + 105, + 108, + 100, + 45, + 102, + 111, + 108, + 100, + 101, + 114, + 47, + 102, + 105, + 108, + 101, + 45, + 105, + 110, + 45, + 99, + 104, + 105, + 108, + 100, + 45, + 50, + 34, + 58, + 123, + 34, + 105, + 100, + 34, + 58, + 34, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, 49, + 34, + 125, + 125, + 125 +]; + +const expectedManifestDataWithFallback = [ + 123, + 34, + 109, + 97, + 110, + 105, + 102, + 101, + 115, + 116, + 34, + 58, + 34, + 97, + 114, + 119, + 101, + 97, + 118, + 101, + 47, + 112, + 97, + 116, + 104, + 115, + 34, + 44, + 34, + 118, + 101, + 114, + 115, + 105, + 111, + 110, + 34, + 58, + 34, + 48, + 46, + 50, 46, 48, 34, 44, 34, + 102, + 97, + 108, + 108, + 98, + 97, + 99, + 107, + 34, + 58, + 123, + 34, + 105, + 100, + 34, + 58, + 34, + 102, + 97, + 108, + 108, + 98, + 97, + 99, + 107, + 45, + 116, + 120, + 45, + 105, + 100, + 34, + 125, + 44, + 34, 105, 110, 100, @@ -598,3 +1234,4 @@ const expectedManifestData = [ 125, 125 ]; +// diff --git a/test/entities/manifest_data_test.dart b/test/entities/manifest_data_test.dart index 3ca2ada16b..a21eb80a1e 100644 --- a/test/entities/manifest_data_test.dart +++ b/test/entities/manifest_data_test.dart @@ -4,6 +4,7 @@ import 'package:ardrive/entities/entities.dart'; import 'package:ardrive/entities/manifest_data.dart'; import 'package:ardrive/models/daos/daos.dart'; import 'package:ardrive/models/database/database.dart'; +import 'package:ardrive/utils/logger.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/utils.dart'; import 'package:mocktail/mocktail.dart'; @@ -228,7 +229,7 @@ void main() { manifest.toJson(), equals({ 'manifest': 'arweave/paths', - 'version': '0.1.0', + 'version': '0.2.0', 'index': {'path': 'file-in-root-1'}, 'paths': { 'file-in-root-1': { @@ -253,6 +254,49 @@ void main() { })); }); + test( + 'returns a ManifestEntity with a valid expected manifest shape when a fallback is provided', + () async { + final builder = ManifestDataBuilder( + folderRepository: folderRepository, + fileRepository: fileRepository, + ); + + final manifest = await builder.build( + folderNode: stubRootFolderNode, + fallbackTxId: 'fallback-tx-id', + ); + + expect( + manifest.toJson(), + equals({ + 'manifest': 'arweave/paths', + 'version': '0.2.0', + 'index': {'path': 'file-in-root-1'}, + 'fallback': {'id': 'fallback-tx-id'}, + 'paths': { + 'file-in-root-1': { + 'id': '0000000000000000000000000000000000000000001' + }, + 'file-in-root-2': { + 'id': '0000000000000000000000000000000000000000001' + }, + 'parent-folder/file-in-parent-1': { + 'id': '0000000000000000000000000000000000000000001' + }, + 'parent-folder/file-in-parent-2': { + 'id': '0000000000000000000000000000000000000000001' + }, + 'parent-folder/child-folder/file-in-child-1': { + 'id': '0000000000000000000000000000000000000000001' + }, + 'parent-folder/child-folder/file-in-child-2': { + 'id': '0000000000000000000000000000000000000000001' + } + } + })); + }); + test( 'returns a ManifestEntity with a valid expected manifest shape with a nested child folder', () async { @@ -269,7 +313,7 @@ void main() { manifest.toJson(), equals({ 'manifest': 'arweave/paths', - 'version': '0.1.0', + 'version': '0.2.0', 'index': {'path': 'file-in-child-1'}, 'paths': { 'file-in-child-1': { @@ -310,6 +354,52 @@ void main() { owner: await wallet.getOwner(), ); + logger.d(dataItem.data.toString()); + + expect(dataItem.tags.length, equals(5)); + expect(decodeBase64ToString(dataItem.tags[0].name), equals('App-Name')); + expect(decodeBase64ToString(dataItem.tags[0].value), + equals('ArDrive-App')); + expect(decodeBase64ToString(dataItem.tags[1].name), + equals('App-Platform')); + expect(decodeBase64ToString(dataItem.tags[1].value), equals('Android')); + expect( + decodeBase64ToString(dataItem.tags[2].name), equals('App-Version')); + expect(decodeBase64ToString(dataItem.tags[2].value), equals('1.3.3.7')); + expect( + decodeBase64ToString(dataItem.tags[3].name), equals('Unix-Time')); + expect(decodeBase64ToString(dataItem.tags[3].value).length, equals(10)); + expect(decodeBase64ToString(dataItem.tags[4].name), + equals('Content-Type')); + expect(decodeBase64ToString(dataItem.tags[4].value), + equals('application/x.arweave-manifest+json')); + + expect(dataItem.target, equals('')); + expect(dataItem.owner, equals(await wallet.getOwner())); + + expect(dataItem.data, equals(expectedManifestDataVersion020)); + }); + test( + 'returns a DataItem with the expected tags, owner, and data with fallback', + () async { + final builder = ManifestDataBuilder( + folderRepository: folderRepository, + fileRepository: fileRepository, + ); + + final manifest = await builder.build( + folderNode: stubRootFolderNode, + fallbackTxId: 'fallback-tx-id', + ); + + final wallet = getTestWallet(); + + AppPlatform.setMockPlatform(platform: SystemPlatform.Android); + + final dataItem = await manifest.asPreparedDataItem( + owner: await wallet.getOwner(), + ); + expect(dataItem.tags.length, equals(5)); expect(decodeBase64ToString(dataItem.tags[0].name), equals('App-Name')); expect(decodeBase64ToString(dataItem.tags[0].value), @@ -331,7 +421,7 @@ void main() { expect(dataItem.target, equals('')); expect(dataItem.owner, equals(await wallet.getOwner())); - expect(dataItem.data, equals(expectedManifestData)); + expect(dataItem.data, equals(expectedManifestDataWithFallback)); }); }); });