mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
fix: timestamp handling on Android
This commit is contained in:
Generated
+1
@@ -1403,6 +1403,7 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
|
||||
@@ -28,6 +28,7 @@ serde_json = "1.0.151"
|
||||
sha2 = { version = "0.11.0", optional = true }
|
||||
socket2 = { version = "0.6.5", optional = true }
|
||||
thiserror = "2.0.19"
|
||||
time = { version = "0.3.54", features = ["parsing"], optional = true }
|
||||
tokio = { version = "1.53.1", features = ["full"] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"], optional = true }
|
||||
tokio-stream = "0.1.19"
|
||||
@@ -44,7 +45,7 @@ x509-parser = { version = "0.18.1", features = ["verify"], optional = true }
|
||||
default = []
|
||||
crypto = ["ed25519-dalek", "rcgen", "rsa", "sha2", "tokio-util"]
|
||||
discovery = ["http", "multicast"]
|
||||
http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "if-addrs", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"]
|
||||
http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "if-addrs", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "time", "tokio-rustls", "tokio-util", "x509-parser"]
|
||||
multicast = ["if-addrs", "socket2", "tokio-util"]
|
||||
webrtc-signaling = ["tokio-tungstenite"]
|
||||
webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"]
|
||||
|
||||
@@ -34,6 +34,9 @@ pub enum FileUploadTarget {
|
||||
|
||||
/// The server writes the file to this path (created or truncated)
|
||||
/// and reports the result on `result_tx`.
|
||||
///
|
||||
/// Timestamps provided in the sender's file metadata are applied to the
|
||||
/// written file, so the application does not need to set them itself.
|
||||
Path {
|
||||
/// The path to write the file to.
|
||||
path: PathBuf,
|
||||
@@ -49,6 +52,9 @@ pub enum FileUploadTarget {
|
||||
|
||||
/// The server writes the file to this raw file descriptor (Android only)
|
||||
/// and reports the result on `result_tx`.
|
||||
///
|
||||
/// Timestamps provided in the sender's file metadata are applied through
|
||||
/// the descriptor, which also covers SAF documents that have no path.
|
||||
#[cfg(target_os = "android")]
|
||||
Fd {
|
||||
/// The raw file descriptor to write the file to.
|
||||
@@ -65,6 +71,20 @@ pub enum FileUploadTarget {
|
||||
},
|
||||
}
|
||||
|
||||
/// The sender-provided timestamps of an uploaded file, applied to the written
|
||||
/// file for [FileUploadTarget::Path] and [FileUploadTarget::Fd].
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(crate) struct FileTimestamps {
|
||||
pub modified: Option<std::time::SystemTime>,
|
||||
pub accessed: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
impl FileTimestamps {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.modified.is_none() && self.accessed.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of receiving an uploaded file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SaveResult {
|
||||
@@ -84,6 +104,7 @@ pub(crate) async fn save_req_to_target(
|
||||
target: FileUploadTarget,
|
||||
file_size: u64,
|
||||
expected_sha256: Option<&str>,
|
||||
timestamps: FileTimestamps,
|
||||
) -> SaveResult {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -109,6 +130,7 @@ pub(crate) async fn save_req_to_target(
|
||||
},
|
||||
file_size,
|
||||
progress_tx,
|
||||
timestamps,
|
||||
);
|
||||
(binary_tx, result_rx, Some(result_tx))
|
||||
}
|
||||
@@ -129,6 +151,7 @@ pub(crate) async fn save_req_to_target(
|
||||
},
|
||||
file_size,
|
||||
progress_tx,
|
||||
timestamps,
|
||||
);
|
||||
(binary_tx, result_rx, Some(result_tx))
|
||||
}
|
||||
@@ -226,13 +249,15 @@ fn spawn_file_writer(
|
||||
open: impl Future<Output = Result<tokio::fs::File, String>> + Send + 'static,
|
||||
expected_size: u64,
|
||||
progress_tx: Option<mpsc::Sender<u64>>,
|
||||
timestamps: FileTimestamps,
|
||||
) -> (mpsc::Sender<Bytes>, oneshot::Receiver<Result<(), String>>) {
|
||||
let (binary_tx, mut binary_rx) = mpsc::channel::<Bytes>(UPLOAD_CHANNEL_CAPACITY);
|
||||
let (internal_tx, internal_rx) = oneshot::channel::<Result<(), String>>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result =
|
||||
write_file_from_receiver(open, expected_size, &mut binary_rx, progress_tx).await;
|
||||
write_file_from_receiver(open, expected_size, &mut binary_rx, progress_tx, timestamps)
|
||||
.await;
|
||||
// Unblock the request handler if it is still sending chunks.
|
||||
binary_rx.close();
|
||||
let _ = internal_tx.send(result);
|
||||
@@ -248,11 +273,16 @@ fn spawn_file_writer(
|
||||
///
|
||||
/// The file is truncated to the written size, so that a target that pointed at
|
||||
/// a longer, pre-existing file cannot keep a tail of the old content.
|
||||
///
|
||||
/// The sender-provided `timestamps` are applied to the completely written
|
||||
/// file. This happens on the still-open handle: an Android file descriptor has
|
||||
/// no path to address the file by afterwards.
|
||||
async fn write_file_from_receiver(
|
||||
open: impl Future<Output = Result<tokio::fs::File, String>>,
|
||||
expected_size: u64,
|
||||
rx: &mut mpsc::Receiver<Bytes>,
|
||||
progress_tx: Option<mpsc::Sender<u64>>,
|
||||
timestamps: FileTimestamps,
|
||||
) -> Result<(), String> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
@@ -295,5 +325,26 @@ async fn write_file_from_receiver(
|
||||
tracing::warn!("Could not truncate file to {written} bytes: {e}");
|
||||
}
|
||||
|
||||
// The timestamps are applied last because the writes and the truncation
|
||||
// above update the modification time themselves.
|
||||
//
|
||||
// Best-effort: the provider backing an Android file descriptor may not
|
||||
// support changing timestamps, which must not fail the completed transfer.
|
||||
if !timestamps.is_empty() {
|
||||
let mut times = std::fs::FileTimes::new();
|
||||
if let Some(modified) = timestamps.modified {
|
||||
times = times.set_modified(modified);
|
||||
}
|
||||
if let Some(accessed) = timestamps.accessed {
|
||||
times = times.set_accessed(accessed);
|
||||
}
|
||||
let file = file.into_std().await;
|
||||
// Also closes the file, off the async runtime like tokio::fs does.
|
||||
let result = tokio::task::spawn_blocking(move || file.set_times(times)).await;
|
||||
if let Ok(Err(e)) = result {
|
||||
tracing::warn!("Could not set file timestamps: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::http::server::common::error::AppError;
|
||||
use crate::http::server::common::pin::check_pin;
|
||||
use crate::http::server::common::query::parse_query;
|
||||
use crate::http::server::common::response::{empty_body, BoxedBody, JsonResponse};
|
||||
use crate::http::server::common::save::{FileUploadTarget, SaveResult};
|
||||
use crate::http::server::common::save::{FileTimestamps, FileUploadTarget, SaveResult};
|
||||
use crate::http::server::common::session::{
|
||||
FileStatusV2, PendingSessionV2, SessionFileV2, SessionStateV2, UploadSessionV2,
|
||||
};
|
||||
@@ -385,6 +385,13 @@ pub(crate) async fn upload(
|
||||
|
||||
let file_size = file_dto.size;
|
||||
let expected_sha256 = file_dto.sha256.clone();
|
||||
let timestamps = match &file_dto.metadata {
|
||||
Some(metadata) => FileTimestamps {
|
||||
modified: metadata.modified_time(),
|
||||
accessed: metadata.accessed_time(),
|
||||
},
|
||||
None => FileTimestamps::default(),
|
||||
};
|
||||
let (target_tx, target_rx) = oneshot::channel::<FileUploadTarget>();
|
||||
|
||||
let event = ServerEventV2::FileUpload {
|
||||
@@ -403,8 +410,14 @@ pub(crate) async fn upload(
|
||||
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
};
|
||||
|
||||
let result =
|
||||
common::save::save_req_to_target(req, target, file_size, expected_sha256.as_deref()).await;
|
||||
let result = common::save::save_req_to_target(
|
||||
req,
|
||||
target,
|
||||
file_size,
|
||||
expected_sha256.as_deref(),
|
||||
timestamps,
|
||||
)
|
||||
.await;
|
||||
|
||||
upload_guard.finish(result).await;
|
||||
|
||||
|
||||
@@ -115,3 +115,86 @@ pub struct FileMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub accessed: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
impl FileMetadata {
|
||||
/// The `modified` timestamp parsed as a [`std::time::SystemTime`],
|
||||
/// or `None` when absent or not parsable.
|
||||
pub fn modified_time(&self) -> Option<std::time::SystemTime> {
|
||||
parse_timestamp(self.modified.as_deref()?)
|
||||
}
|
||||
|
||||
/// The `accessed` timestamp parsed as a [`std::time::SystemTime`],
|
||||
/// or `None` when absent or not parsable.
|
||||
pub fn accessed_time(&self) -> Option<std::time::SystemTime> {
|
||||
parse_timestamp(self.accessed.as_deref()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an RFC 3339 timestamp (e.g. `2026-08-01T10:20:30.456Z`), the format
|
||||
/// the protocol uses for file metadata.
|
||||
#[cfg(feature = "http")]
|
||||
fn parse_timestamp(value: &str) -> Option<std::time::SystemTime> {
|
||||
match time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) {
|
||||
Ok(parsed) => Some(parsed.into()),
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not parse file timestamp {value:?}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "http"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
fn metadata(modified: &str) -> FileMetadata {
|
||||
FileMetadata {
|
||||
modified: Some(modified.to_string()),
|
||||
accessed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_utc_timestamp() {
|
||||
assert_eq!(
|
||||
metadata("2000-01-01T00:00:00Z").modified_time(),
|
||||
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(946_684_800)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fractional_seconds() {
|
||||
// The Dart implementation sends `DateTime.toIso8601String()` of a UTC
|
||||
// value, which includes fractional seconds: 1970-01-01T00:00:00.500Z.
|
||||
assert_eq!(
|
||||
metadata("1970-01-01T00:00:00.500Z").modified_time(),
|
||||
Some(SystemTime::UNIX_EPOCH + Duration::from_millis(500)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_offset_timestamp() {
|
||||
assert_eq!(
|
||||
metadata("2000-01-01T01:00:00+01:00").modified_time(),
|
||||
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(946_684_800)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_invalid_timestamp() {
|
||||
assert_eq!(metadata("yesterday").modified_time(), None);
|
||||
assert_eq!(metadata("2000-01-01T00:00:00").modified_time(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_absent_timestamp() {
|
||||
let metadata = FileMetadata {
|
||||
modified: None,
|
||||
accessed: None,
|
||||
};
|
||||
assert_eq!(metadata.modified_time(), None);
|
||||
assert_eq!(metadata.accessed_time(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use localsend::http::server::common::save::FileUploadTarget;
|
||||
use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2, SessionEndReasonV2};
|
||||
use localsend::http::server::{start_with_port, ServerConfigV2};
|
||||
use localsend::http::state::ClientInfo;
|
||||
use localsend::model::transfer::FileDto;
|
||||
use localsend::model::transfer::{FileDto, FileMetadata};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU16, AtomicU64, Ordering};
|
||||
@@ -370,6 +370,61 @@ async fn test_full_upload_flow() {
|
||||
assert_status(result, 403);
|
||||
}
|
||||
|
||||
/// The sender-provided metadata timestamps are applied to the written file.
|
||||
#[tokio::test]
|
||||
async fn test_upload_applies_file_timestamps() {
|
||||
let save_dir = std::env::temp_dir().join(format!("localsend-test-{}", uuid::Uuid::new_v4()));
|
||||
tokio::fs::create_dir_all(&save_dir).await.unwrap();
|
||||
|
||||
let server = start_test_server(None, true, Some(save_dir.clone())).await;
|
||||
let client = LsHttpClientV2::try_new_without_cert().unwrap();
|
||||
|
||||
let bytes = b"hello".to_vec();
|
||||
let mut file = file_dto("file-a", "a.bin", bytes.len() as u64);
|
||||
file.metadata = Some(FileMetadata {
|
||||
modified: Some("2020-08-15T10:20:30.500Z".to_string()),
|
||||
accessed: None,
|
||||
});
|
||||
|
||||
let response = client
|
||||
.prepare_upload(
|
||||
ProtocolType::Http,
|
||||
"127.0.0.1",
|
||||
server.port,
|
||||
None,
|
||||
prepare_upload_request(&[file]),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.response
|
||||
.unwrap();
|
||||
|
||||
upload_bytes(
|
||||
&client,
|
||||
server.port,
|
||||
&response.session_id,
|
||||
"file-a",
|
||||
&response.files["file-a"],
|
||||
&bytes,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let modified = tokio::fs::metadata(save_dir.join("file-a"))
|
||||
.await
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
modified,
|
||||
std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(1_597_486_830_500),
|
||||
);
|
||||
|
||||
tokio::fs::remove_dir_all(&save_dir).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upload_with_matching_sha256() {
|
||||
let server = start_test_server(None, true, None).await;
|
||||
|
||||
@@ -91,6 +91,9 @@ abstract class RsHttpServer implements RustOpaqueInterface {
|
||||
///
|
||||
/// The progress (fraction of [file_size]) is emitted on [sink]
|
||||
/// while the file is being received.
|
||||
///
|
||||
/// Timestamps provided in the sender's file metadata are applied to the
|
||||
/// written file by the server.
|
||||
Stream<double> respondFileUpload({required String sessionId, required String fileId, String? path, int? fileDescriptor, required BigInt fileSize});
|
||||
|
||||
/// Answers the pending [RsServerEvent::WebPrepareDownload] event.
|
||||
|
||||
@@ -6117,6 +6117,9 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer {
|
||||
///
|
||||
/// The progress (fraction of [file_size]) is emitted on [sink]
|
||||
/// while the file is being received.
|
||||
///
|
||||
/// Timestamps provided in the sender's file metadata are applied to the
|
||||
/// written file by the server.
|
||||
Stream<double> respondFileUpload({
|
||||
required String sessionId,
|
||||
required String fileId,
|
||||
|
||||
@@ -683,12 +683,6 @@ Future<void> _handleFileUpload({
|
||||
}
|
||||
|
||||
try {
|
||||
await applyFileTimestamps(
|
||||
target: target,
|
||||
lastModified: dartFile.metadata?.lastModified,
|
||||
lastAccessed: dartFile.metadata?.lastAccessed,
|
||||
);
|
||||
|
||||
String? filePath;
|
||||
bool savedToGallery = false;
|
||||
if (shouldSaveToGallery) {
|
||||
|
||||
@@ -115,29 +115,6 @@ Future<FileSaveTarget> reopenFileSaveTarget(FileSaveTarget target) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Applies the file timestamps after the file has been written to a plain path.
|
||||
Future<void> applyFileTimestamps({
|
||||
required FileSaveTarget target,
|
||||
DateTime? lastModified,
|
||||
DateTime? lastAccessed,
|
||||
}) async {
|
||||
final path = target.path;
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
final file = File(path);
|
||||
if (lastModified != null) {
|
||||
try {
|
||||
await file.setLastModified(lastModified);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (lastAccessed != null) {
|
||||
try {
|
||||
await file.setLastAccessed(lastAccessed);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a file that has been written to the cache directory into the
|
||||
/// OS gallery (Photos/Videos).
|
||||
///
|
||||
|
||||
@@ -57,6 +57,9 @@ class HttpServerService {
|
||||
/// The returned stream emits the progress (fraction of [fileSize]) while the
|
||||
/// file is being received and closes once the file has been received
|
||||
/// completely (or errors when saving failed).
|
||||
///
|
||||
/// Timestamps provided in the sender's file metadata are applied to the
|
||||
/// written file by the Rust server.
|
||||
Stream<double> respondFileUpload({
|
||||
required String sessionId,
|
||||
required String fileId,
|
||||
|
||||
@@ -640,10 +640,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.10"
|
||||
type_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
@@ -1594,6 +1594,7 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
|
||||
@@ -388,6 +388,9 @@ impl RsHttpServer {
|
||||
///
|
||||
/// The progress (fraction of [file_size]) is emitted on [sink]
|
||||
/// while the file is being received.
|
||||
///
|
||||
/// Timestamps provided in the sender's file metadata are applied to the
|
||||
/// written file by the server.
|
||||
pub async fn respond_file_upload(
|
||||
&self,
|
||||
sink: StreamSink<f64>,
|
||||
|
||||
Reference in New Issue
Block a user