feat: show checksum progress by byte

This commit is contained in:
Tien Do Nam
2026-07-30 15:48:12 +02:00
parent f50bcb372b
commit fe1bc539a0
13 changed files with 725 additions and 60 deletions
+16 -1
View File
@@ -3,9 +3,11 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:localsend_app/config/theme.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/state/send/send_session_state.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/favorites_provider.dart';
import 'package:localsend_app/provider/network/send_provider.dart';
import 'package:localsend_app/provider/progress_provider.dart';
import 'package:localsend_app/util/favorites.dart';
import 'package:localsend_app/util/native/taskbar_helper.dart';
import 'package:localsend_app/widget/animations/initial_fade_transition.dart';
@@ -33,6 +35,19 @@ class SendPage extends StatefulWidget {
State<SendPage> createState() => _SendPageState();
}
double _hashProgress(SendSessionState sendState, ProgressNotifier progressNotifier) {
final files = sendState.files.values;
final totalBytes = files.fold<int>(0, (prev, curr) => prev + curr.file.size);
if (totalBytes == 0) {
return sendState.files.isEmpty ? 0 : sendState.hashedFileCount / sendState.files.length;
}
final hashedBytes = files.fold<double>(
0,
(prev, curr) => prev + progressNotifier.getProgress(sessionId: sendState.sessionId, fileId: curr.file.id) * curr.file.size,
);
return (hashedBytes / totalBytes).clamp(0, 1);
}
class _SendPageState extends State<SendPage> with Refena {
Device? _myDevice;
Device? _targetDevice;
@@ -145,7 +160,7 @@ class _SendPageState extends State<SendPage> with Refena {
SizedBox(
width: 200,
child: LinearProgressIndicator(
value: sendState.hashedFileCount / sendState.files.length,
value: _hashProgress(sendState, ref.watch(progressProvider)),
),
),
],
+25 -1
View File
@@ -140,7 +140,24 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
try {
for (final (:id, :file) in selectedFiles) {
try {
hashes[id] = await calculateFileHash(path: file.path, bytes: file.bytes, cancelToken: hashCancelToken);
hashes[id] = await calculateFileHash(
path: file.path,
bytes: file.bytes,
cancelToken: hashCancelToken,
onProgress: (bytes) {
if (state[sessionId] == null) {
// session has been canceled while calculating the checksums
return;
}
ref
.notifier(progressProvider)
.setProgress(
sessionId: sessionId,
fileId: id,
progress: file.size == 0 ? 1 : (bytes / file.size).clamp(0, 1),
);
},
);
} catch (e) {
if (state[sessionId] != null) {
// Sending the checksum is optional, so a file that cannot be read
@@ -155,6 +172,9 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
return;
}
// Also set for files whose hashing failed, so the progress bar stays
// consistent with the files that are left.
ref.notifier(progressProvider).setProgress(sessionId: sessionId, fileId: id, progress: 1);
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(hashedFileCount: s.hashedFileCount + 1),
@@ -349,6 +369,10 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
file.file.id: fileMap.containsKey(file.file.id) ? file.copyWith(token: fileMap[file.file.id]) : file.copyWith(status: FileStatus.skipped),
};
// The hash progress is no longer needed and must not be mistaken for
// upload progress, which starts at zero for every file.
ref.notifier(progressProvider).removeSession(sessionId);
if (state[sessionId]?.background == false) {
final background = ref.read(settingsProvider).sendMode == SendMode.multiple;
+27 -12
View File
@@ -26,27 +26,38 @@ pub fn sha256_hex(data: &[u8]) -> String {
}
/// Computes the SHA-256 checksum of a file's content, encoded as lowercase hex.
///
/// `progress` is invoked with the cumulative number of bytes hashed as each
/// chunk is consumed, mirroring the upload progress callback.
pub async fn sha256_file_content(
content: FileContent,
cancel_token: &CancellationToken,
progress: impl Fn(u64),
) -> Result<String, HashError> {
let mut hasher = Sha256::new();
match content {
FileContent::Stream(mut receiver) => loop {
let chunk = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(HashError::Cancelled),
chunk = receiver.recv() => chunk,
};
match chunk {
Some(chunk) => hasher.update(&chunk),
None => break,
FileContent::Stream(mut receiver) => {
let mut hashed = 0_u64;
loop {
let chunk = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(HashError::Cancelled),
chunk = receiver.recv() => chunk,
};
match chunk {
Some(chunk) => {
hasher.update(&chunk);
hashed += chunk.len() as u64;
progress(hashed);
}
None => break,
}
}
},
}
FileContent::Path(path) => {
tracing::info!("Hashing file content from path: {}", path.display());
let file = tokio::fs::File::open(&path).await?;
read_and_hash_from_file(&mut hasher, file, cancel_token).await?;
read_and_hash_from_file(&mut hasher, file, cancel_token, progress).await?;
}
#[cfg(target_os = "android")]
FileContent::Fd(fd) => {
@@ -57,7 +68,7 @@ pub async fn sha256_file_content(
// transfers that ownership so it is closed once hashing finishes.
let std_file = unsafe { std::fs::File::from_raw_fd(fd) };
let file = tokio::fs::File::from_std(std_file);
read_and_hash_from_file(&mut hasher, file, cancel_token).await?;
read_and_hash_from_file(&mut hasher, file, cancel_token, progress).await?;
}
}
@@ -69,10 +80,12 @@ async fn read_and_hash_from_file(
hasher: &mut Sha256,
mut file: tokio::fs::File,
cancel_token: &CancellationToken,
progress: impl Fn(u64),
) -> Result<(), HashError> {
use tokio::io::AsyncReadExt;
let mut buffer = vec![0u8; HASH_BUFFER_SIZE];
let mut hashed = 0_u64;
loop {
let read = tokio::select! {
biased;
@@ -83,6 +96,8 @@ async fn read_and_hash_from_file(
break;
}
hasher.update(&buffer[..read]);
hashed += read as u64;
progress(hashed);
}
Ok(())
}
+32 -13
View File
@@ -12,26 +12,40 @@ async fn hash_file_from_path() {
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&path, b"hello world").await.unwrap();
let hash = sha256_file_content(FileContent::Path(path.clone()), &CancellationToken::new())
.await
.unwrap();
let hash = sha256_file_content(
FileContent::Path(path.clone()),
&CancellationToken::new(),
|_| {},
)
.await
.unwrap();
assert_eq!(hash, HELLO_WORLD_HASH);
tokio::fs::remove_file(&path).await.unwrap();
}
/// A file larger than the internal buffer must be hashed across multiple reads.
/// A file larger than the internal buffer must be hashed across multiple reads,
/// reporting the cumulative progress after each of them.
#[tokio::test]
async fn hash_large_file_from_path() {
let content: Vec<u8> = (0..500_000).map(|i| (i % 251) as u8).collect();
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&path, &content).await.unwrap();
let hash = sha256_file_content(FileContent::Path(path.clone()), &CancellationToken::new())
.await
.unwrap();
let progress = std::sync::Mutex::new(Vec::new());
let hash = sha256_file_content(
FileContent::Path(path.clone()),
&CancellationToken::new(),
|hashed| progress.lock().unwrap().push(hashed),
)
.await
.unwrap();
assert_eq!(hash, sha256_hex(&content));
let progress = progress.into_inner().unwrap();
assert!(progress.len() > 1);
assert!(progress.windows(2).all(|pair| pair[0] < pair[1]));
assert_eq!(*progress.last().unwrap(), content.len() as u64);
tokio::fs::remove_file(&path).await.unwrap();
}
@@ -43,7 +57,7 @@ async fn hash_cancelled_while_reading() {
let handle = tokio::spawn({
let token = cancel_token.clone();
async move { sha256_file_content(FileContent::Stream(rx), &token).await }
async move { sha256_file_content(FileContent::Stream(rx), &token, |_| {}).await }
});
// The sender stays alive, so hashing only ends because of the cancellation.
@@ -63,7 +77,7 @@ async fn hash_cancelled_before_start() {
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let result = sha256_file_content(FileContent::Path(path.clone()), &cancel_token).await;
let result = sha256_file_content(FileContent::Path(path.clone()), &cancel_token, |_| {}).await;
assert!(matches!(result, Err(HashError::Cancelled)));
tokio::fs::remove_file(&path).await.unwrap();
@@ -73,7 +87,8 @@ async fn hash_cancelled_before_start() {
async fn hash_missing_file_fails() {
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
let result = sha256_file_content(FileContent::Path(path), &CancellationToken::new()).await;
let result =
sha256_file_content(FileContent::Path(path), &CancellationToken::new(), |_| {}).await;
assert!(result.is_err());
}
@@ -86,9 +101,13 @@ async fn hash_stream() {
tx.send(bytes::Bytes::from_static(b"world")).await.unwrap();
});
let hash = sha256_file_content(FileContent::Stream(rx), &CancellationToken::new())
.await
.unwrap();
let hash = sha256_file_content(
FileContent::Stream(rx),
&CancellationToken::new(),
|_| {},
)
.await
.unwrap();
assert_eq!(hash, HELLO_WORLD_HASH);
}
@@ -4,9 +4,12 @@
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/rust/frb_generated.dart';
part 'crypto.freezed.dart';
Future<void> verifyCert({required String cert, required String publicKey}) =>
RustLib.instance.api.crateApiCryptoVerifyCert(cert: cert, publicKey: publicKey);
@@ -16,7 +19,8 @@ Future<KeyPair> generateKeyPair() => RustLib.instance.api.crateApiCryptoGenerate
/// certificate whose SHA-256 fingerprint identifies the device.
Future<SecurityContext> generateSecurityContext() => RustLib.instance.api.crateApiCryptoGenerateSecurityContext();
/// Computes the SHA-256 checksum of a file, encoded as lowercase hex.
/// Computes the SHA-256 checksum of a file, reported as the final
/// [RsHashFileEvent::Done] event of the returned stream.
///
/// The file is read chunk by chunk, so it is never fully loaded into memory.
/// Cancelling [cancel_token] aborts the read, so hashing a large file does not
@@ -25,7 +29,7 @@ Future<SecurityContext> generateSecurityContext() => RustLib.instance.api.crateA
/// Exactly one content source must be provided:
/// a [path] to a regular file, a [file_descriptor] (Android only), or [bytes]
/// for a file that only lives in memory.
Future<String> hashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}) =>
Stream<RsHashFileEvent> hashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}) =>
RustLib.instance.api.crateApiCryptoHashFile(path: path, fileDescriptor: fileDescriptor, bytes: bytes, cancelToken: cancelToken);
class KeyPair {
@@ -46,6 +50,23 @@ class KeyPair {
other is KeyPair && runtimeType == other.runtimeType && privateKey == other.privateKey && publicKey == other.publicKey;
}
@freezed
sealed class RsHashFileEvent with _$RsHashFileEvent {
const RsHashFileEvent._();
/// Cumulative number of bytes hashed so far.
/// Throttled, so not every hashed chunk is reported.
const factory RsHashFileEvent.progress({
required BigInt bytes,
}) = RsHashFileEvent_Progress;
/// Hashing has finished; [hash] is the checksum, encoded as lowercase hex.
/// Always the last event of the stream.
const factory RsHashFileEvent.done({
required String hash,
}) = RsHashFileEvent_Done;
}
class SecurityContext {
final String privateKey;
final String publicKey;
@@ -0,0 +1,306 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'crypto.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RsHashFileEvent {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RsHashFileEvent);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'RsHashFileEvent()';
}
}
/// @nodoc
class $RsHashFileEventCopyWith<$Res> {
$RsHashFileEventCopyWith(RsHashFileEvent _, $Res Function(RsHashFileEvent) __);
}
/// Adds pattern-matching-related methods to [RsHashFileEvent].
extension RsHashFileEventPatterns on RsHashFileEvent {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( RsHashFileEvent_Progress value)? progress,TResult Function( RsHashFileEvent_Done value)? done,required TResult orElse(),}){
final _that = this;
switch (_that) {
case RsHashFileEvent_Progress() when progress != null:
return progress(_that);case RsHashFileEvent_Done() when done != null:
return done(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( RsHashFileEvent_Progress value) progress,required TResult Function( RsHashFileEvent_Done value) done,}){
final _that = this;
switch (_that) {
case RsHashFileEvent_Progress():
return progress(_that);case RsHashFileEvent_Done():
return done(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( RsHashFileEvent_Progress value)? progress,TResult? Function( RsHashFileEvent_Done value)? done,}){
final _that = this;
switch (_that) {
case RsHashFileEvent_Progress() when progress != null:
return progress(_that);case RsHashFileEvent_Done() when done != null:
return done(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( BigInt bytes)? progress,TResult Function( String hash)? done,required TResult orElse(),}) {final _that = this;
switch (_that) {
case RsHashFileEvent_Progress() when progress != null:
return progress(_that.bytes);case RsHashFileEvent_Done() when done != null:
return done(_that.hash);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( BigInt bytes) progress,required TResult Function( String hash) done,}) {final _that = this;
switch (_that) {
case RsHashFileEvent_Progress():
return progress(_that.bytes);case RsHashFileEvent_Done():
return done(_that.hash);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( BigInt bytes)? progress,TResult? Function( String hash)? done,}) {final _that = this;
switch (_that) {
case RsHashFileEvent_Progress() when progress != null:
return progress(_that.bytes);case RsHashFileEvent_Done() when done != null:
return done(_that.hash);case _:
return null;
}
}
}
/// @nodoc
class RsHashFileEvent_Progress extends RsHashFileEvent {
const RsHashFileEvent_Progress({required this.bytes}): super._();
final BigInt bytes;
/// Create a copy of RsHashFileEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RsHashFileEvent_ProgressCopyWith<RsHashFileEvent_Progress> get copyWith => _$RsHashFileEvent_ProgressCopyWithImpl<RsHashFileEvent_Progress>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RsHashFileEvent_Progress&&(identical(other.bytes, bytes) || other.bytes == bytes));
}
@override
int get hashCode => Object.hash(runtimeType,bytes);
@override
String toString() {
return 'RsHashFileEvent.progress(bytes: $bytes)';
}
}
/// @nodoc
abstract mixin class $RsHashFileEvent_ProgressCopyWith<$Res> implements $RsHashFileEventCopyWith<$Res> {
factory $RsHashFileEvent_ProgressCopyWith(RsHashFileEvent_Progress value, $Res Function(RsHashFileEvent_Progress) _then) = _$RsHashFileEvent_ProgressCopyWithImpl;
@useResult
$Res call({
BigInt bytes
});
}
/// @nodoc
class _$RsHashFileEvent_ProgressCopyWithImpl<$Res>
implements $RsHashFileEvent_ProgressCopyWith<$Res> {
_$RsHashFileEvent_ProgressCopyWithImpl(this._self, this._then);
final RsHashFileEvent_Progress _self;
final $Res Function(RsHashFileEvent_Progress) _then;
/// Create a copy of RsHashFileEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? bytes = null,}) {
return _then(RsHashFileEvent_Progress(
bytes: null == bytes ? _self.bytes : bytes // ignore: cast_nullable_to_non_nullable
as BigInt,
));
}
}
/// @nodoc
class RsHashFileEvent_Done extends RsHashFileEvent {
const RsHashFileEvent_Done({required this.hash}): super._();
final String hash;
/// Create a copy of RsHashFileEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RsHashFileEvent_DoneCopyWith<RsHashFileEvent_Done> get copyWith => _$RsHashFileEvent_DoneCopyWithImpl<RsHashFileEvent_Done>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RsHashFileEvent_Done&&(identical(other.hash, hash) || other.hash == hash));
}
@override
int get hashCode => Object.hash(runtimeType,hash);
@override
String toString() {
return 'RsHashFileEvent.done(hash: $hash)';
}
}
/// @nodoc
abstract mixin class $RsHashFileEvent_DoneCopyWith<$Res> implements $RsHashFileEventCopyWith<$Res> {
factory $RsHashFileEvent_DoneCopyWith(RsHashFileEvent_Done value, $Res Function(RsHashFileEvent_Done) _then) = _$RsHashFileEvent_DoneCopyWithImpl;
@useResult
$Res call({
String hash
});
}
/// @nodoc
class _$RsHashFileEvent_DoneCopyWithImpl<$Res>
implements $RsHashFileEvent_DoneCopyWith<$Res> {
_$RsHashFileEvent_DoneCopyWithImpl(this._self, this._then);
final RsHashFileEvent_Done _self;
final $Res Function(RsHashFileEvent_Done) _then;
/// Create a copy of RsHashFileEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? hash = null,}) {
return _then(RsHashFileEvent_Done(
hash: null == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
@@ -249,7 +249,7 @@ abstract class RustLibApi extends BaseApi {
Future<SecurityContext> crateApiCryptoGenerateSecurityContext();
Future<String> crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken});
Stream<RsHashFileEvent> crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken});
Future<RsMulticast> crateApiMulticastStartMulticast({
required String group,
@@ -1697,31 +1697,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
@override
Future<String> crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_opt_String(path, serializer);
sse_encode_opt_box_autoadd_i_32(fileDescriptor, serializer);
sse_encode_opt_list_prim_u_8_strict(bytes, serializer);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken(cancelToken, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 46, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
decodeErrorData: sse_decode_AnyhowException,
Stream<RsHashFileEvent> crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}) {
final sink = RustStreamSink<RsHashFileEvent>();
unawaited(
handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_StreamSink_rs_hash_file_event_Sse(sink, serializer);
sse_encode_opt_String(path, serializer);
sse_encode_opt_box_autoadd_i_32(fileDescriptor, serializer);
sse_encode_opt_list_prim_u_8_strict(bytes, serializer);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken(cancelToken, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 46, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta: kCrateApiCryptoHashFileConstMeta,
argValues: [sink, path, fileDescriptor, bytes, cancelToken],
apiImpl: this,
),
constMeta: kCrateApiCryptoHashFileConstMeta,
argValues: [path, fileDescriptor, bytes, cancelToken],
apiImpl: this,
),
);
return sink.stream;
}
TaskConstMeta get kCrateApiCryptoHashFileConstMeta => const TaskConstMeta(
debugName: 'hash_file',
argNames: ['path', 'fileDescriptor', 'bytes', 'cancelToken'],
argNames: ['sink', 'path', 'fileDescriptor', 'bytes', 'cancelToken'],
);
@override
@@ -2207,6 +2212,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError();
}
@protected
RustStreamSink<RsHashFileEvent> dco_decode_StreamSink_rs_hash_file_event_Sse(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
throw UnimplementedError();
}
@protected
RustStreamSink<RsMulticastDiscovered> dco_decode_StreamSink_rs_multicast_discovered_Sse(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2790,6 +2801,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
RsHashFileEvent dco_decode_rs_hash_file_event(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
switch (raw[0]) {
case 0:
return RsHashFileEvent_Progress(
bytes: dco_decode_u_64(raw[1]),
);
case 1:
return RsHashFileEvent_Done(
hash: dco_decode_String(raw[1]),
);
default:
throw Exception('unreachable');
}
}
@protected
RsHttpClientError dco_decode_rs_http_client_error(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -3363,6 +3391,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError('Unreachable ()');
}
@protected
RustStreamSink<RsHashFileEvent> sse_decode_StreamSink_rs_hash_file_event_Sse(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
throw UnimplementedError('Unreachable ()');
}
@protected
RustStreamSink<RsMulticastDiscovered> sse_decode_StreamSink_rs_multicast_discovered_Sse(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -4028,6 +4062,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return ResultWithPublicKeyRegisterResponseDto(publicKey: var_publicKey, body: var_body);
}
@protected
RsHashFileEvent sse_decode_rs_hash_file_event(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var tag_ = sse_decode_i_32(deserializer);
switch (tag_) {
case 0:
var var_bytes = sse_decode_u_64(deserializer);
return RsHashFileEvent_Progress(bytes: var_bytes);
case 1:
var var_hash = sse_decode_String(deserializer);
return RsHashFileEvent_Done(hash: var_hash);
default:
throw UnimplementedError('');
}
}
@protected
RsHttpClientError sse_decode_rs_http_client_error(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -4650,6 +4701,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
void sse_encode_StreamSink_rs_hash_file_event_Sse(RustStreamSink<RsHashFileEvent> self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(
self.setupAndSerialize(
codec: SseCodec(
decodeSuccessData: sse_decode_rs_hash_file_event,
decodeErrorData: sse_decode_AnyhowException,
),
),
serializer,
);
}
@protected
void sse_encode_StreamSink_rs_multicast_discovered_Sse(RustStreamSink<RsMulticastDiscovered> self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -5262,6 +5327,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_register_response_dto(self.body, serializer);
}
@protected
void sse_encode_rs_hash_file_event(RsHashFileEvent self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
switch (self) {
case RsHashFileEvent_Progress(bytes: final bytes):
sse_encode_i_32(0, serializer);
sse_encode_u_64(bytes, serializer);
case RsHashFileEvent_Done(hash: final hash):
sse_encode_i_32(1, serializer);
sse_encode_String(hash, serializer);
}
}
@protected
void sse_encode_rs_http_client_error(RsHttpClientError self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -192,6 +192,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
RustStreamSink<Uint8List> dco_decode_StreamSink_list_prim_u_8_strict_Sse(dynamic raw);
@protected
RustStreamSink<RsHashFileEvent> dco_decode_StreamSink_rs_hash_file_event_Sse(dynamic raw);
@protected
RustStreamSink<RsMulticastDiscovered> dco_decode_StreamSink_rs_multicast_discovered_Sse(dynamic raw);
@@ -417,6 +420,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ResultWithPublicKeyRegisterResponseDto dco_decode_result_with_public_key_register_response_dto(dynamic raw);
@protected
RsHashFileEvent dco_decode_rs_hash_file_event(dynamic raw);
@protected
RsHttpClientError dco_decode_rs_http_client_error(dynamic raw);
@@ -623,6 +629,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
RustStreamSink<Uint8List> sse_decode_StreamSink_list_prim_u_8_strict_Sse(SseDeserializer deserializer);
@protected
RustStreamSink<RsHashFileEvent> sse_decode_StreamSink_rs_hash_file_event_Sse(SseDeserializer deserializer);
@protected
RustStreamSink<RsMulticastDiscovered> sse_decode_StreamSink_rs_multicast_discovered_Sse(SseDeserializer deserializer);
@@ -850,6 +859,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ResultWithPublicKeyRegisterResponseDto sse_decode_result_with_public_key_register_response_dto(SseDeserializer deserializer);
@protected
RsHashFileEvent sse_decode_rs_hash_file_event(SseDeserializer deserializer);
@protected
RsHttpClientError sse_decode_rs_http_client_error(SseDeserializer deserializer);
@@ -1100,6 +1112,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_StreamSink_list_prim_u_8_strict_Sse(RustStreamSink<Uint8List> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_rs_hash_file_event_Sse(RustStreamSink<RsHashFileEvent> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_rs_multicast_discovered_Sse(RustStreamSink<RsMulticastDiscovered> self, SseSerializer serializer);
@@ -1329,6 +1344,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_result_with_public_key_register_response_dto(ResultWithPublicKeyRegisterResponseDto self, SseSerializer serializer);
@protected
void sse_encode_rs_hash_file_event(RsHashFileEvent self, SseSerializer serializer);
@protected
void sse_encode_rs_http_client_error(RsHttpClientError self, SseSerializer serializer);
@@ -194,6 +194,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
RustStreamSink<Uint8List> dco_decode_StreamSink_list_prim_u_8_strict_Sse(dynamic raw);
@protected
RustStreamSink<RsHashFileEvent> dco_decode_StreamSink_rs_hash_file_event_Sse(dynamic raw);
@protected
RustStreamSink<RsMulticastDiscovered> dco_decode_StreamSink_rs_multicast_discovered_Sse(dynamic raw);
@@ -419,6 +422,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ResultWithPublicKeyRegisterResponseDto dco_decode_result_with_public_key_register_response_dto(dynamic raw);
@protected
RsHashFileEvent dco_decode_rs_hash_file_event(dynamic raw);
@protected
RsHttpClientError dco_decode_rs_http_client_error(dynamic raw);
@@ -625,6 +631,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
RustStreamSink<Uint8List> sse_decode_StreamSink_list_prim_u_8_strict_Sse(SseDeserializer deserializer);
@protected
RustStreamSink<RsHashFileEvent> sse_decode_StreamSink_rs_hash_file_event_Sse(SseDeserializer deserializer);
@protected
RustStreamSink<RsMulticastDiscovered> sse_decode_StreamSink_rs_multicast_discovered_Sse(SseDeserializer deserializer);
@@ -852,6 +861,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ResultWithPublicKeyRegisterResponseDto sse_decode_result_with_public_key_register_response_dto(SseDeserializer deserializer);
@protected
RsHashFileEvent sse_decode_rs_hash_file_event(SseDeserializer deserializer);
@protected
RsHttpClientError sse_decode_rs_http_client_error(SseDeserializer deserializer);
@@ -1102,6 +1114,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_StreamSink_list_prim_u_8_strict_Sse(RustStreamSink<Uint8List> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_rs_hash_file_event_Sse(RustStreamSink<RsHashFileEvent> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_rs_multicast_discovered_Sse(RustStreamSink<RsMulticastDiscovered> self, SseSerializer serializer);
@@ -1331,6 +1346,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_result_with_public_key_register_response_dto(ResultWithPublicKeyRegisterResponseDto self, SseSerializer serializer);
@protected
void sse_encode_rs_hash_file_event(RsHashFileEvent self, SseSerializer serializer);
@protected
void sse_encode_rs_http_client_error(RsHttpClientError self, SseSerializer serializer);
@@ -8,28 +8,44 @@ import 'package:localsend_isolates/util/android_channel.dart';
///
/// The file is read and hashed in Rust, so this only occupies the Dart isolate
/// while waiting for the result.
/// [onProgress] is called with the cumulative number of bytes hashed so far;
/// the events are already throttled on the Rust side.
/// Cancelling [cancelToken] aborts the calculation and throws.
Future<String> calculateFileHash({
required String? path,
required List<int>? bytes,
required RsCancellationToken cancelToken,
void Function(int bytes)? onProgress,
}) async {
final Stream<rust_crypto.RsHashFileEvent> events;
if (path != null) {
if (path.startsWith('content://')) {
// Android SAF files can only be read through a file descriptor.
// The descriptor is closed by Rust after the file has been read.
final fileDescriptor = await getFileDescriptorAndroid(uri: path);
return await rust_crypto.hashFile(fileDescriptor: fileDescriptor, cancelToken: cancelToken);
events = rust_crypto.hashFile(fileDescriptor: fileDescriptor, cancelToken: cancelToken);
} else {
events = rust_crypto.hashFile(path: path, cancelToken: cancelToken);
}
return await rust_crypto.hashFile(path: path, cancelToken: cancelToken);
}
if (bytes != null) {
return await rust_crypto.hashFile(
} else if (bytes != null) {
events = rust_crypto.hashFile(
bytes: bytes is Uint8List ? bytes : Uint8List.fromList(bytes),
cancelToken: cancelToken,
);
} else {
throw ArgumentError('Either path or bytes must be provided');
}
throw ArgumentError('Either path or bytes must be provided');
await for (final event in events) {
switch (event) {
case rust_crypto.RsHashFileEvent_Progress():
onProgress?.call(event.bytes.toInt());
case rust_crypto.RsHashFileEvent_Done():
return event.hash;
}
}
// The Done event is always the last event, so this is unreachable unless
// the Rust side failed, in which case the loop above has already thrown.
throw StateError('Hashing ended without a result');
}
@@ -1,4 +1,5 @@
use crate::api::cancel::RsCancellationToken;
use crate::frb_generated::StreamSink;
pub fn verify_cert(cert: String, public_key: String) -> anyhow::Result<()> {
localsend::crypto::cert::verify_cert_from_pem(cert, Some(&public_key))
@@ -40,7 +41,20 @@ pub struct SecurityContext {
pub certificate_hash: String,
}
/// Computes the SHA-256 checksum of a file, encoded as lowercase hex.
/// An event emitted while a file is being hashed by [hash_file].
#[derive(Clone)]
pub enum RsHashFileEvent {
/// Cumulative number of bytes hashed so far.
/// Throttled, so not every hashed chunk is reported.
Progress { bytes: u64 },
/// Hashing has finished; [hash] is the checksum, encoded as lowercase hex.
/// Always the last event of the stream.
Done { hash: String },
}
/// Computes the SHA-256 checksum of a file, reported as the final
/// [RsHashFileEvent::Done] event of the returned stream.
///
/// The file is read chunk by chunk, so it is never fully loaded into memory.
/// Cancelling [cancel_token] aborts the read, so hashing a large file does not
@@ -50,11 +64,12 @@ pub struct SecurityContext {
/// a [path] to a regular file, a [file_descriptor] (Android only), or [bytes]
/// for a file that only lives in memory.
pub async fn hash_file(
sink: StreamSink<RsHashFileEvent>,
path: Option<String>,
file_descriptor: Option<i32>,
bytes: Option<Vec<u8>>,
cancel_token: &RsCancellationToken,
) -> anyhow::Result<String> {
) -> anyhow::Result<()> {
let content = match (path, file_descriptor, bytes) {
(Some(path), None, None) => localsend::model::transfer::FileContent::Path(path.into()),
(None, Some(file_descriptor), None) => {
@@ -68,9 +83,33 @@ pub async fn hash_file(
anyhow::bail!("File descriptors are only supported on Android");
}
}
(None, None, Some(bytes)) => return Ok(localsend::crypto::hash::sha256_hex(&bytes)),
(None, None, Some(bytes)) => {
let hash = localsend::crypto::hash::sha256_hex(&bytes);
let _ = sink.add(RsHashFileEvent::Done { hash });
return Ok(());
}
_ => anyhow::bail!("Exactly one content source must be provided"),
};
Ok(localsend::crypto::hash::sha256_file_content(content, &cancel_token.inner).await?)
// Progress events with throttling
let last_emit = std::cell::Cell::new(None::<std::time::Instant>);
let progress = {
let sink = sink.clone();
move |hashed| {
let now = std::time::Instant::now();
if let Some(last) = last_emit.get() {
if now.duration_since(last) < std::time::Duration::from_millis(20) {
return;
}
}
last_emit.set(Some(now));
let _ = sink.add(RsHashFileEvent::Progress { bytes: hashed });
}
};
let hash =
localsend::crypto::hash::sha256_file_content(content, &cancel_token.inner, progress)
.await?;
let _ = sink.add(RsHashFileEvent::Done { hash });
Ok(())
}
@@ -2694,6 +2694,10 @@ fn wire__crate__api__crypto__hash_file_impl(
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_sink = <StreamSink<
crate::api::crypto::RsHashFileEvent,
flutter_rust_bridge::for_generated::SseCodec,
>>::sse_decode(&mut deserializer);
let api_path = <Option<String>>::sse_decode(&mut deserializer);
let api_file_descriptor = <Option<i32>>::sse_decode(&mut deserializer);
let api_bytes = <Option<Vec<u8>>>::sse_decode(&mut deserializer);
@@ -2724,6 +2728,7 @@ fn wire__crate__api__crypto__hash_file_impl(
}
let api_cancel_token_guard = api_cancel_token_guard.unwrap();
let output_ok = crate::api::crypto::hash_file(
api_sink,
api_path,
api_file_descriptor,
api_bytes,
@@ -3422,6 +3427,19 @@ impl SseDecode for StreamSink<Vec<u8>, flutter_rust_bridge::for_generated::SseCo
}
}
impl SseDecode
for StreamSink<
crate::api::crypto::RsHashFileEvent,
flutter_rust_bridge::for_generated::SseCodec,
>
{
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <String>::sse_decode(deserializer);
return StreamSink::deserialize(inner);
}
}
impl SseDecode
for StreamSink<
crate::api::multicast::RsMulticastDiscovered,
@@ -4091,6 +4109,26 @@ impl SseDecode for crate::api::http::ResultWithPublicKeyRegisterResponseDto {
}
}
impl SseDecode for crate::api::crypto::RsHashFileEvent {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut tag_ = <i32>::sse_decode(deserializer);
match tag_ {
0 => {
let mut var_bytes = <u64>::sse_decode(deserializer);
return crate::api::crypto::RsHashFileEvent::Progress { bytes: var_bytes };
}
1 => {
let mut var_hash = <String>::sse_decode(deserializer);
return crate::api::crypto::RsHashFileEvent::Done { hash: var_hash };
}
_ => {
unimplemented!("");
}
}
}
}
impl SseDecode for crate::api::http::RsHttpClientError {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -5345,6 +5383,33 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::http::ResultWithPublicKeyRegi
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::crypto::RsHashFileEvent {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
crate::api::crypto::RsHashFileEvent::Progress { bytes } => {
[0.into_dart(), bytes.into_into_dart().into_dart()].into_dart()
}
crate::api::crypto::RsHashFileEvent::Done { hash } => {
[1.into_dart(), hash.into_into_dart().into_dart()].into_dart()
}
_ => {
unimplemented!("");
}
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::crypto::RsHashFileEvent
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::crypto::RsHashFileEvent>
for crate::api::crypto::RsHashFileEvent
{
fn into_into_dart(self) -> crate::api::crypto::RsHashFileEvent {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::http::RsHttpClientError {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
@@ -6029,6 +6094,18 @@ impl SseEncode for StreamSink<Vec<u8>, flutter_rust_bridge::for_generated::SseCo
}
}
impl SseEncode
for StreamSink<
crate::api::crypto::RsHashFileEvent,
flutter_rust_bridge::for_generated::SseCodec,
>
{
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
unimplemented!("")
}
}
impl SseEncode
for StreamSink<
crate::api::multicast::RsMulticastDiscovered,
@@ -6575,6 +6652,25 @@ impl SseEncode for crate::api::http::ResultWithPublicKeyRegisterResponseDto {
}
}
impl SseEncode for crate::api::crypto::RsHashFileEvent {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
match self {
crate::api::crypto::RsHashFileEvent::Progress { bytes } => {
<i32>::sse_encode(0, serializer);
<u64>::sse_encode(bytes, serializer);
}
crate::api::crypto::RsHashFileEvent::Done { hash } => {
<i32>::sse_encode(1, serializer);
<String>::sse_encode(hash, serializer);
}
_ => {
unimplemented!("");
}
}
}
}
impl SseEncode for crate::api::http::RsHttpClientError {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
+1 -1
View File
@@ -8,7 +8,7 @@ anyhow = "1.0.95"
axum = { version = "0.8.1", features = ["ws"] }
base64 = "0.22.1"
futures-util = "0.3.31"
localsend = { path = "../core" }
localsend = { path = "../packages/core" }
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.135"
tokio = { version = "1.43.0", features = ["full"] }