diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 9725014cba..6635f14dc3 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, ), ); @@ -505,6 +520,9 @@ class UploadCubit extends Cubit { canShowSettings: showSettings, showReviewButtonText: false, selectedManifestSelections: _selectedManifestModels, + uploadFileAsCustomManifest: false, + // only applies for single file uploads + shouldShowCustomManifestCheckbox: false, ), ); } @@ -1092,6 +1110,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 d8878d0859..46300290d2 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.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/services/arweave/arweave_service.dart b/lib/services/arweave/arweave_service.dart index 208567b90e..5802f3f9e4 100644 --- a/lib/services/arweave/arweave_service.dart +++ b/lib/services/arweave/arweave_service.dart @@ -264,6 +264,7 @@ class ArweaveService { yield licenseComposedQuery.data!.transactions.edges .map((e) => e.node) + .where((e) => e.tags.any((t) => t.name == 'License')) .toList(); } } 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/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/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); + }); + }); +}