diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 61ce3c5054..fdbec9f454 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'; @@ -110,6 +112,7 @@ class UploadCubit extends Cubit { UploadMethod? _manifestUploadMethod; bool _isManifestsUploadCancelled = false; + bool _isUploadingCustomManifest = false; void updateManifestSelection(List selections) { _selectedManifestModels.clear(); @@ -126,6 +129,11 @@ class UploadCubit extends Cubit { _manifestUploadMethod = method; } + void setIsUploadingCustomManifest(bool value) { + _isUploadingCustomManifest = value; + emit((state as UploadReady).copyWith(isUploadingCustomManifest: value)); + } + Future prepareManifestUpload() async { final manifestModels = _selectedManifestModels .map((e) => UploadManifestModel( @@ -150,7 +158,7 @@ class UploadCubit extends Cubit { fileId: manifestModels[i].existingManifestFileId, ) .getSingle(); - + await _createManifestCubit.prepareManifestTx( manifestName: manifestFileEntry.name, folderId: manifestFileEntry.parentFolderId, @@ -441,6 +449,10 @@ class UploadCubit extends Cubit { bool showArnsCheckbox = false; if (_targetDrive.isPublic && _files.length == 1) { + final isACustomManifest = await isCustomManifest(_files.first.ioFile); + + logger.d('Is a custom manifest: $isACustomManifest'); + emit( UploadReady( params: (state as UploadReadyToPrepare).params, @@ -461,6 +473,8 @@ class UploadCubit extends Cubit { arnsRecords: _ants, showReviewButtonText: false, selectedManifestSelections: _selectedManifestModels, + isCustomManifest: isACustomManifest, + isUploadingCustomManifest: false, ), ); @@ -505,6 +519,8 @@ class UploadCubit extends Cubit { canShowSettings: showSettings, showReviewButtonText: false, selectedManifestSelections: _selectedManifestModels, + isCustomManifest: false, // only applies for single file uploads + isUploadingCustomManifest: false, ), ); } @@ -886,6 +902,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 @@ -1032,9 +1052,15 @@ class UploadCubit extends Cubit { } if (manifestFileEntries.isNotEmpty) { - // load arns names - await _arnsRepository - .getAntRecordsForWallet(_auth.currentUser.walletAddress); + try { + // load arns names + await _arnsRepository + .getAntRecordsForWallet(_auth.currentUser.walletAddress); + } catch (e) { + logger.e( + 'Error getting ant records for wallet. Proceeding with the upload...', + e); + } } emit( @@ -1083,6 +1109,21 @@ class UploadCubit extends Cubit { return; } + if (_isUploadingCustomManifest) { + 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..ea89a5b35c 100644 --- a/lib/blocs/upload/upload_state.dart +++ b/lib/blocs/upload/upload_state.dart @@ -115,7 +115,8 @@ class UploadReady extends UploadState { final bool isArConnect; final bool showReviewButtonText; - + final bool isCustomManifest; + final bool isUploadingCustomManifest; UploadReady({ required this.paymentInfo, required this.uploadIsPublic, @@ -136,6 +137,8 @@ class UploadReady extends UploadState { required this.arnsRecords, required this.showReviewButtonText, required this.selectedManifestSelections, + required this.isCustomManifest, + required this.isUploadingCustomManifest, }); // copyWith @@ -160,6 +163,8 @@ class UploadReady extends UploadState { List? arnsRecords, bool? showReviewButtonText, List? selectedManifestSelections, + bool? isCustomManifest, + bool? isUploadingCustomManifest, }) { return UploadReady( loadingArNSNames: loadingArNSNames ?? this.loadingArNSNames, @@ -184,6 +189,9 @@ class UploadReady extends UploadState { showReviewButtonText: showReviewButtonText ?? this.showReviewButtonText, selectedManifestSelections: selectedManifestSelections ?? this.selectedManifestSelections, + isCustomManifest: isCustomManifest ?? this.isCustomManifest, + isUploadingCustomManifest: + isUploadingCustomManifest ?? this.isUploadingCustomManifest, ); } diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index d8878d0859..9db202434c 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'; @@ -1977,6 +1975,22 @@ class _UploadReadyWidget extends StatelessWidget { ), ), ], + if (state.isCustomManifest) ...[ + const SizedBox(height: 8), + ArDriveCheckBox( + title: 'Convert this file to an Arweave manifest.', + checked: state.isUploadingCustomManifest, + useNewIcons: true, + titleStyle: typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, + ), + onChange: (value) { + context + .read() + .setIsUploadingCustomManifest(value); + }, + ), + ], ], ); }, diff --git a/lib/main.dart b/lib/main.dart index 0fe72a818a..1b75627f74 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -75,6 +75,7 @@ late ConfigService configService; late ArweaveService arweave; late TurboUploadService _turboUpload; late PaymentService _turboPayment; +late Database db; void main() async { await runZonedGuarded(() async { @@ -129,11 +130,14 @@ Future _initializeServices() async { final config = configService.config; + db = Database(); + arweave = ArweaveService( Arweave( gatewayUrl: Uri.parse(config.defaultArweaveGatewayForDataRequest.url), ), ArDriveCrypto(), + db.driveDao, configService, ); _turboUpload = config.useTurboUpload @@ -395,7 +399,7 @@ class AppState extends State { ), ), ), - RepositoryProvider(create: (_) => Database()), + RepositoryProvider(create: (_) => db), RepositoryProvider( create: (context) => context.read().profileDao), RepositoryProvider( diff --git a/lib/services/arweave/arweave_service.dart b/lib/services/arweave/arweave_service.dart index 0c1ff7f729..92ae0ee0d5 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 ?? @@ -588,15 +590,24 @@ 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)!, + ); + + 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/sync/domain/repositories/sync_repository.dart b/lib/sync/domain/repositories/sync_repository.dart index 190e5bfec2..b3878d2340 100644 --- a/lib/sync/domain/repositories/sync_repository.dart +++ b/lib/sync/domain/repositories/sync_repository.dart @@ -376,6 +376,7 @@ class _SyncRepository implements SyncRepository { // // It also adds the encryption keys onto the drive models which isn't touched by the // later system. + final userDriveEntities = await _arweave.getUniqueUserDriveEntities( wallet, password, diff --git a/lib/utils/is_custom_manifest.dart b/lib/utils/is_custom_manifest.dart new file mode 100644 index 0000000000..a5793778ae --- /dev/null +++ b/lib/utils/is_custom_manifest.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; + +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 { + if (file.contentType == 'application/json') { + /// Read the first 100 bytes of the file + final first100Bytes = file.openReadStream(0, 100); + + await for (var bytes in first100Bytes) { + // verify if file contains "arweave/paths"f + if (utf8.decode(bytes).contains('arweave/paths')) { + return true; + } + } + } + return false; +} diff --git a/test/utils/is_custom_manifest_test.dart b/test/utils/is_custom_manifest_test.dart new file mode 100644 index 0000000000..8d5e4d71af --- /dev/null +++ b/test/utils/is_custom_manifest_test.dart @@ -0,0 +1,83 @@ +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))); + + // 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))); + + // Act + final result = await isCustomManifest(mockFile); + + // Assert + expect(result, false); + verify(() => mockFile.openReadStream(0, 100)).called(1); + }); + + test('returns false when file is not JSON', () async { + // Arrange + when(() => mockFile.contentType).thenReturn('text/plain'); + + // 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))); + + // Act + final result = await isCustomManifest(mockFile); + + // Assert + expect(result, false); + verify(() => mockFile.openReadStream(0, 100)).called(1); + }); + }); +}