diff --git a/android/fastlane/metadata/android/en-US/changelogs/160.txt b/android/fastlane/metadata/android/en-US/changelogs/160.txt new file mode 100644 index 0000000000..1870c4090e --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/160.txt @@ -0,0 +1 @@ +- Enable uploads even if contract state retrieval fails diff --git a/android/fastlane/metadata/android/en-US/changelogs/164.txt b/android/fastlane/metadata/android/en-US/changelogs/164.txt new file mode 100644 index 0000000000..f00c950b84 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/164.txt @@ -0,0 +1 @@ +- Added support for uploading custom Arweave manifest files diff --git a/lib/blocs/drive_detail/drive_detail_cubit.dart b/lib/blocs/drive_detail/drive_detail_cubit.dart index 46b7948e07..eeea5c7c01 100644 --- a/lib/blocs/drive_detail/drive_detail_cubit.dart +++ b/lib/blocs/drive_detail/drive_detail_cubit.dart @@ -138,12 +138,21 @@ class DriveDetailCubit extends Cubit { _folderSubscription = Rx.combineLatest3( _driveRepository.watchDrive(driveId: driveId), - _driveDao.watchFolderContents( + _driveDao + .watchFolderContents( driveId, orderBy: contentOrderBy, orderingMode: contentOrderingMode, folderId: folderId, - ), + ) + .handleError((error, stack) { + logger.e('Error watching folder contents', error, stack); + if (error is DriveNotFoundException) { + emit(DriveDetailLoadNotFound()); + } + + return null; + }), _profileCubit.stream.startWith(ProfileCheckingAvailability()), (drive, folderContents, _) async { if (isClosed) { diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 3b7ebf0e3e..fd204d19f2 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -11,6 +11,7 @@ import 'package:ardrive/core/activity_tracker.dart'; import 'package:ardrive/core/upload/domain/repository/upload_repository.dart'; import 'package:ardrive/core/upload/uploader.dart'; import 'package:ardrive/core/upload/view/blocs/upload_manifest_options_bloc.dart'; +import 'package:ardrive/entities/constants.dart'; import 'package:ardrive/main.dart'; import 'package:ardrive/manifest/domain/manifest_repository.dart'; import 'package:ardrive/models/forms/cc.dart'; @@ -21,6 +22,7 @@ import 'package:ardrive/services/config/config_service.dart'; import 'package:ardrive/services/license/license.dart'; import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/utils/constants.dart'; +import 'package:ardrive/utils/is_custom_manifest.dart'; import 'package:ardrive/utils/logger.dart'; import 'package:ardrive/utils/plausible_event_tracker/plausible_custom_event_properties.dart'; import 'package:ardrive/utils/plausible_event_tracker/plausible_event_tracker.dart'; @@ -111,6 +113,9 @@ class UploadCubit extends Cubit { bool _isManifestsUploadCancelled = false; + /// if true, the file will change its content type to `application/x.arweave-manifest+json` + bool _uploadFileAsCustomManifest = false; + void updateManifestSelection(List selections) { _selectedManifestModels.clear(); @@ -126,6 +131,11 @@ class UploadCubit extends Cubit { _manifestUploadMethod = method; } + void setIsUploadingCustomManifest(bool value) { + _uploadFileAsCustomManifest = value; + emit((state as UploadReady).copyWith(uploadFileAsCustomManifest: value)); + } + Future prepareManifestUpload() async { final manifestModels = _selectedManifestModels .map((e) => UploadManifestModel( @@ -441,6 +451,9 @@ class UploadCubit extends Cubit { bool showArnsCheckbox = false; if (_targetDrive.isPublic && _files.length == 1) { + final fileIsACustomManifest = + await isCustomManifest(_files.first.ioFile); + emit( UploadReady( params: (state as UploadReadyToPrepare).params, @@ -461,6 +474,8 @@ class UploadCubit extends Cubit { arnsRecords: _ants, showReviewButtonText: false, selectedManifestSelections: _selectedManifestModels, + shouldShowCustomManifestCheckbox: fileIsACustomManifest, + uploadFileAsCustomManifest: false, ), ); @@ -481,9 +496,11 @@ class UploadCubit extends Cubit { emit(readyState.copyWith( loadingArNSNames: false, showArnsCheckbox: showArnsCheckbox)); } catch (e) { - final readyState = state as UploadReady; - emit(readyState.copyWith( - loadingArNSNamesError: true, loadingArNSNames: false)); + if (state is UploadReady) { + final readyState = state as UploadReady; + emit(readyState.copyWith( + loadingArNSNamesError: true, loadingArNSNames: false)); + } } } else { emit( @@ -505,6 +522,9 @@ class UploadCubit extends Cubit { canShowSettings: showSettings, showReviewButtonText: false, selectedManifestSelections: _selectedManifestModels, + uploadFileAsCustomManifest: false, + // only applies for single file uploads + shouldShowCustomManifestCheckbox: false, ), ); } @@ -886,6 +906,10 @@ class UploadCubit extends Cubit { if (state is UploadReady) { emit((state as UploadReady).copyWith(arnsRecords: value)); } + }).catchError((e) { + logger.e( + 'Error getting ant records for wallet. Proceeding with the upload...', + e); }); _files @@ -1036,7 +1060,9 @@ class UploadCubit extends Cubit { await _arnsRepository .getAntRecordsForWallet(_auth.currentUser.walletAddress); } catch (e) { - logger.e('Error getting ARNS records', e); + logger.e( + 'Error getting ant records for wallet. Proceeding with the upload...', + e); } } @@ -1086,6 +1112,21 @@ class UploadCubit extends Cubit { return; } + if (_uploadFileAsCustomManifest) { + final fileWithCustomContentType = await IOFile.fromData( + await _files.first.ioFile.readAsBytes(), + name: _files.first.ioFile.name, + lastModifiedDate: _files.first.ioFile.lastModifiedDate, + contentType: ContentType.manifest, + ); + + _files.first = UploadFile( + ioFile: fileWithCustomContentType, + parentFolderId: _files.first.parentFolderId, + relativeTo: _files.first.relativeTo, + ); + } + _uploadIsInProgress = true; UploadPlan uploadPlan; diff --git a/lib/blocs/upload/upload_state.dart b/lib/blocs/upload/upload_state.dart index c71c3f7db9..abd64dde47 100644 --- a/lib/blocs/upload/upload_state.dart +++ b/lib/blocs/upload/upload_state.dart @@ -115,6 +115,8 @@ class UploadReady extends UploadState { final bool isArConnect; final bool showReviewButtonText; + final bool shouldShowCustomManifestCheckbox; + final bool uploadFileAsCustomManifest; UploadReady({ required this.paymentInfo, @@ -136,6 +138,8 @@ class UploadReady extends UploadState { required this.arnsRecords, required this.showReviewButtonText, required this.selectedManifestSelections, + required this.shouldShowCustomManifestCheckbox, + required this.uploadFileAsCustomManifest, }); // copyWith @@ -160,6 +164,8 @@ class UploadReady extends UploadState { List? arnsRecords, bool? showReviewButtonText, List? selectedManifestSelections, + bool? shouldShowCustomManifestCheckbox, + bool? uploadFileAsCustomManifest, }) { return UploadReady( loadingArNSNames: loadingArNSNames ?? this.loadingArNSNames, @@ -184,6 +190,10 @@ class UploadReady extends UploadState { showReviewButtonText: showReviewButtonText ?? this.showReviewButtonText, selectedManifestSelections: selectedManifestSelections ?? this.selectedManifestSelections, + shouldShowCustomManifestCheckbox: shouldShowCustomManifestCheckbox ?? + this.shouldShowCustomManifestCheckbox, + uploadFileAsCustomManifest: + uploadFileAsCustomManifest ?? this.uploadFileAsCustomManifest, ); } diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index 0dcc05ccc9..17c963c6ce 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -1,5 +1,3 @@ -// ignore_for_file: use_build_context_synchronously - import 'dart:async'; import 'dart:math'; @@ -1968,6 +1966,22 @@ class _UploadReadyWidget extends StatelessWidget { ), ), ], + if (state.shouldShowCustomManifestCheckbox) ...[ + const SizedBox(height: 8), + ArDriveCheckBox( + title: 'Convert this file to an Arweave manifest.', + checked: state.uploadFileAsCustomManifest, + useNewIcons: true, + titleStyle: typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, + ), + onChange: (value) { + context + .read() + .setIsUploadingCustomManifest(value); + }, + ), + ], ], ); }, diff --git a/lib/core/upload/bundle_signer.dart b/lib/core/upload/bundle_signer.dart index ab82c4b099..20ef26c9e6 100644 --- a/lib/core/upload/bundle_signer.dart +++ b/lib/core/upload/bundle_signer.dart @@ -59,7 +59,11 @@ class ArweaveBundleTransactionSigner implements BundleTransactionSigner { logger.i('Adding tip...'); - await pstService.addCommunityTipToTx(bundleTx); + try { + await pstService.addCommunityTipToTx(bundleTx); + } catch (e) { + logger.e('Error adding community tip to transaction. Proceeding.', e); + } logger.i('Tip added'); diff --git a/lib/core/upload/cost_calculator.dart b/lib/core/upload/cost_calculator.dart index 0304280d6b..f2fa323d93 100644 --- a/lib/core/upload/cost_calculator.dart +++ b/lib/core/upload/cost_calculator.dart @@ -1,6 +1,7 @@ import 'package:ardrive/services/arweave/arweave.dart'; import 'package:ardrive/turbo/turbo.dart'; import 'package:ardrive/utils/logger.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/utils.dart'; import 'package:equatable/equatable.dart'; import 'package:pst/pst.dart'; @@ -64,7 +65,12 @@ class UploadCostEstimateCalculatorForAR extends ArDriveUploadCostCalculator { }) async { final costInAR = await _arweaveService.getPrice(byteSize: totalSize); - final pstFee = await _pstService.getPSTFee(costInAR); + Winston pstFee = Winston(BigInt.zero); + try { + pstFee = await _pstService.getPSTFee(costInAR); + } catch (e) { + logger.e('Error adding community tip to transaction. Proceeding.', e); + } final totalCostAR = costInAR + pstFee.value; diff --git a/lib/core/upload/transaction_signer.dart b/lib/core/upload/transaction_signer.dart index 8862583570..038a45c9db 100644 --- a/lib/core/upload/transaction_signer.dart +++ b/lib/core/upload/transaction_signer.dart @@ -65,7 +65,11 @@ class ArweaveTransactionSigner implements TransactionSigner { ..addApplicationTags(version: version) ..addUTags(); - await pstService.addCommunityTipToTx(dataTx); + try { + await pstService.addCommunityTipToTx(dataTx); + } catch (e) { + logger.e('Error adding community tip to transaction. Proceeding.', e); + } // Don't include the file's Content-Type tag if it is meant to be private. if (!isPrivate) { diff --git a/lib/main.dart b/lib/main.dart index 690ff52e24..ed78bf6912 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -169,6 +169,7 @@ Future initializeServices({bool deleteDatabase = false}) async { gatewayUrl: Uri.parse(config.defaultArweaveGatewayForDataRequest.url), ), ArDriveCrypto(), + _database.driveDao, configService, ); _turboUpload = config.useTurboUpload diff --git a/lib/pages/drive_detail/drive_detail_page.dart b/lib/pages/drive_detail/drive_detail_page.dart index 802769088a..bb9b28145f 100644 --- a/lib/pages/drive_detail/drive_detail_page.dart +++ b/lib/pages/drive_detail/drive_detail_page.dart @@ -132,7 +132,8 @@ class _DriveDetailPageState extends State { child: BlocListener( listener: (context, state) { if (state is DrivesLoadSuccess) { - if (state.userDrives.isNotEmpty) { + if (state.userDrives.isNotEmpty || + state.sharedDrives.isNotEmpty) { final driveDetailState = context.read().state; if (driveDetailState is DriveDetailLoadSuccess && diff --git a/lib/services/arweave/arweave_service.dart b/lib/services/arweave/arweave_service.dart index 0c1ff7f729..5802f3f9e4 100644 --- a/lib/services/arweave/arweave_service.dart +++ b/lib/services/arweave/arweave_service.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/entities/entities.dart'; +import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; import 'package:ardrive/services/arweave/arweave_service_exception.dart'; import 'package:ardrive/services/arweave/error/gateway_error.dart'; import 'package:ardrive/services/arweave/get_segmented_transaction_from_drive_strategy.dart'; @@ -42,12 +43,13 @@ const kMaxNumberOfTransactionsPerPage = 100; class ArweaveService { Arweave client; final ArDriveCrypto _crypto; - + final DriveDao _driveDao; final ArtemisClient _gql; ArweaveService( this.client, this._crypto, + this._driveDao, ConfigService configService, { ArtemisClient? artemisClient, }) : _gql = artemisClient ?? @@ -262,6 +264,7 @@ class ArweaveService { yield licenseComposedQuery.data!.transactions.edges .map((e) => e.node) + .where((e) => e.tags.any((t) => t.name == 'License')) .toList(); } } @@ -588,15 +591,26 @@ class ArweaveService { continue; } - final driveKey = - driveTx.getTag(EntityTag.drivePrivacy) == DrivePrivacyTag.private - ? await _crypto.deriveDriveKey( - wallet, - driveTx.getTag(EntityTag.driveId)!, - password, - ) - : null; + SecretKey? driveKey; + + if (driveTx.getTag(EntityTag.drivePrivacy) == DrivePrivacyTag.private) { + driveKey = await _driveDao.getDriveKeyFromMemory( + driveTx.getTag(EntityTag.driveId)!, + ); + + if (driveKey == null) { + driveKey = await _crypto.deriveDriveKey( + wallet, + driveTx.getTag(EntityTag.driveId)!, + password, + ); + _driveDao.putDriveKeyInMemory( + driveID: driveTx.getTag(EntityTag.driveId)!, + driveKey: driveKey, + ); + } + } try { final drive = await DriveEntity.fromTransaction( driveTx, diff --git a/lib/services/arweave/graphql/queries/LicenseDataBundled.graphql b/lib/services/arweave/graphql/queries/LicenseDataBundled.graphql index c6fe7719f5..5a2ddf97ae 100644 --- a/lib/services/arweave/graphql/queries/LicenseDataBundled.graphql +++ b/lib/services/arweave/graphql/queries/LicenseDataBundled.graphql @@ -1,7 +1,6 @@ query LicenseComposed($transactionIds: [ID!]) { transactions( ids: $transactionIds - tags: [{ name: "License", values: [""], op: NEQ }] ) { edges { node { diff --git a/lib/sync/domain/repositories/sync_repository.dart b/lib/sync/domain/repositories/sync_repository.dart index 4874e3445e..190e5bfec2 100644 --- a/lib/sync/domain/repositories/sync_repository.dart +++ b/lib/sync/domain/repositories/sync_repository.dart @@ -245,16 +245,20 @@ class _SyncRepository implements SyncRepository { logger.i('Syncing licenses...'); - final licenseTxIds = {}; - final revisionsToSyncLicense = (await _driveDao - .allFileRevisionsWithLicenseReferencedButNotSynced() - .get()) - ..retainWhere((rev) => licenseTxIds.add(rev.licenseTxId!)); - logger.d('Found ${revisionsToSyncLicense.length} licenses to sync'); - - await _updateLicenses( - revisionsToSyncLicense: revisionsToSyncLicense, - ); + try { + final licenseTxIds = {}; + final revisionsToSyncLicense = (await _driveDao + .allFileRevisionsWithLicenseReferencedButNotSynced() + .get()) + ..retainWhere((rev) => licenseTxIds.add(rev.licenseTxId!)); + logger.d('Found ${revisionsToSyncLicense.length} licenses to sync'); + + await _updateLicenses( + revisionsToSyncLicense: revisionsToSyncLicense, + ); + } catch (e) { + logger.e('Error syncing licenses. Proceeding.', e); + } logger.i('Licenses synced'); diff --git a/lib/utils/is_custom_manifest.dart b/lib/utils/is_custom_manifest.dart new file mode 100644 index 0000000000..41e2201bba --- /dev/null +++ b/lib/utils/is_custom_manifest.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:ardrive/utils/logger.dart'; +import 'package:ardrive_io/ardrive_io.dart'; + +/// Checks if a file is an Arweave manifest file by examining its content type and contents. +/// +/// Returns true if the file has JSON content type and contains the string "arweave/paths", +/// which indicates it follows the Arweave path manifest specification. +Future isCustomManifest(IOFile file) async { + try { + if (file.contentType == 'application/json') { + final fileLength = await file.length; + + int bytesToRead = 100; + + if (fileLength < bytesToRead) { + bytesToRead = fileLength; + } + + /// Read the first 100 bytes of the file + final first100Bytes = file.openReadStream(0, bytesToRead); + + String content = ''; + + await for (var bytes in first100Bytes) { + content += utf8.decode(bytes); + } + + /// verify if file contains "arweave/paths" + if (content.contains('arweave/paths')) { + return true; + } + } + return false; + } catch (e) { + logger.e('Error checking if file is a custom manifest', e); + return false; + } +} diff --git a/packages/ardrive_uploader/lib/src/cost_calculator.dart b/packages/ardrive_uploader/lib/src/cost_calculator.dart index 4c1e9499b2..348ea8f779 100644 --- a/packages/ardrive_uploader/lib/src/cost_calculator.dart +++ b/packages/ardrive_uploader/lib/src/cost_calculator.dart @@ -1,10 +1,10 @@ +import 'package:ardrive_uploader/src/utils/logger.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; import 'package:arweave/utils.dart'; // ignore: depend_on_referenced_packages import 'package:equatable/equatable.dart'; import 'package:pst/pst.dart'; -import 'package:ardrive_uploader/src/utils/logger.dart'; abstract class ArDriveUploadCostCalculator { Future calculateCost({required int totalSize}); @@ -67,7 +67,14 @@ class UploadCostEstimateCalculatorForAR extends ArDriveUploadCostCalculator { .get('/price/$totalSize') .then((res) => BigInt.parse(res.body)); - final pstFee = await _pstService.getPSTFee(costInAR); + late final Winston pstFee; + + try { + pstFee = await _pstService.getPSTFee(costInAR); + } catch (e) { + logger.e('Error adding community tip to transaction. Proceeding.', e); + pstFee = Winston(BigInt.zero); + } final totalCostAR = costInAR + pstFee.value; diff --git a/packages/ardrive_uploader/lib/src/upload_dispatcher.dart b/packages/ardrive_uploader/lib/src/upload_dispatcher.dart index 83e634aea9..f30820d792 100644 --- a/packages/ardrive_uploader/lib/src/upload_dispatcher.dart +++ b/packages/ardrive_uploader/lib/src/upload_dispatcher.dart @@ -29,6 +29,14 @@ class UploadDispatcher { }) async { try { if (task is FileUploadTask) { + logger.d('Preparing data items for file ${task.file.name}...'); + + controller.updateProgress( + task: task.copyWith( + status: UploadStatus.creatingMetadata, + ), + ); + final uploadPreparation = await prepareDataItems( file: task.file, metadata: task.metadata, diff --git a/pubspec.yaml b/pubspec.yaml index a87d6a64e0..615851ac59 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Secure, permanent storage publish_to: 'none' -version: 2.57.0 +version: 2.58.0 environment: sdk: '>=3.2.0 <4.0.0' diff --git a/test/utils/is_custom_manifest_test.dart b/test/utils/is_custom_manifest_test.dart new file mode 100644 index 0000000000..23fbfebaf7 --- /dev/null +++ b/test/utils/is_custom_manifest_test.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:ardrive/utils/is_custom_manifest.dart'; +import 'package:ardrive_io/ardrive_io.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockIOFile extends Mock implements IOFile {} + +void main() { + late MockIOFile mockFile; + + setUp(() { + mockFile = MockIOFile(); + registerFallbackValue(0); + registerFallbackValue(100); + }); + + group('isCustomManifest', () { + test('returns true when file is JSON and contains arweave/paths', () async { + // Arrange + const jsonContent = + '{"manifest":"arweave/paths","version":"0.1.0","index":{"path":"hello_world.html"},"paths":{"hello_world.html":{"id":"KlwrMWFW9ckVKa8pCGk9a8EjwzYZ7jNVUVHdcE2YkHo"}}}'; + final bytes = utf8.encode(jsonContent); + + when(() => mockFile.contentType).thenReturn('application/json'); + when(() => mockFile.openReadStream(any(), any())) + .thenAnswer((_) => Stream.value(Uint8List.fromList(bytes))); + when(() => mockFile.length).thenReturn(bytes.length); + // Act + final result = await isCustomManifest(mockFile); + + // Assert + expect(result, true); + verify(() => mockFile.openReadStream(0, 100)).called(1); + }); + + test('returns false when file is JSON but does not contain arweave/paths', + () async { + // Arrange + const jsonContent = '{"version": 1, "type": "regular"}'; + final bytes = utf8.encode(jsonContent); + + when(() => mockFile.contentType).thenReturn('application/json'); + when(() => mockFile.openReadStream(any(), any())) + .thenAnswer((_) => Stream.value(Uint8List.fromList(bytes))); + when(() => mockFile.length).thenReturn(bytes.length); + + // Act + final result = await isCustomManifest(mockFile); + + // Assert + expect(result, false); + verify(() => mockFile.openReadStream(0, 33)).called(1); + }); + + test('returns false when file is not JSON', () async { + // Arrange + when(() => mockFile.contentType).thenReturn('text/plain'); + when(() => mockFile.length).thenReturn(0); + // Act + final result = await isCustomManifest(mockFile); + + // Assert + expect(result, false); + verifyNever(() => mockFile.openReadStream(any(), any())); + }); + + test('returns false when stream is empty', () async { + // Arrange + when(() => mockFile.contentType).thenReturn('application/json'); + when(() => mockFile.openReadStream(any(), any())) + .thenAnswer((_) => Stream.value(Uint8List(0))); + when(() => mockFile.length).thenReturn(0); + // Act + final result = await isCustomManifest(mockFile); + + // Assert + expect(result, false); + verify(() => mockFile.openReadStream(0, 0)).called(1); + }); + }); +}