fix: timestamp handling on Android

This commit is contained in:
Tien Do Nam
2026-08-01 15:06:45 +02:00
parent 9900953ae9
commit b13bbeb3cd
15 changed files with 226 additions and 37 deletions
+1
View File
@@ -1920,6 +1920,7 @@ dependencies = [
"sha2 0.11.0", "sha2 0.11.0",
"socket2 0.6.5", "socket2 0.6.5",
"thiserror 2.0.19", "thiserror 2.0.19",
"time",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-stream", "tokio-stream",
+1
View File
@@ -1403,6 +1403,7 @@ dependencies = [
"sha2 0.11.0", "sha2 0.11.0",
"socket2 0.6.5", "socket2 0.6.5",
"thiserror 2.0.19", "thiserror 2.0.19",
"time",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-stream", "tokio-stream",
+2 -1
View File
@@ -28,6 +28,7 @@ serde_json = "1.0.151"
sha2 = { version = "0.11.0", optional = true } sha2 = { version = "0.11.0", optional = true }
socket2 = { version = "0.6.5", optional = true } socket2 = { version = "0.6.5", optional = true }
thiserror = "2.0.19" thiserror = "2.0.19"
time = { version = "0.3.54", features = ["parsing"], optional = true }
tokio = { version = "1.53.1", features = ["full"] } tokio = { version = "1.53.1", features = ["full"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"], optional = true } tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"], optional = true }
tokio-stream = "0.1.19" tokio-stream = "0.1.19"
@@ -44,7 +45,7 @@ x509-parser = { version = "0.18.1", features = ["verify"], optional = true }
default = [] default = []
crypto = ["ed25519-dalek", "rcgen", "rsa", "sha2", "tokio-util"] crypto = ["ed25519-dalek", "rcgen", "rsa", "sha2", "tokio-util"]
discovery = ["http", "multicast"] 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"] multicast = ["if-addrs", "socket2", "tokio-util"]
webrtc-signaling = ["tokio-tungstenite"] webrtc-signaling = ["tokio-tungstenite"]
webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"] webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"]
+52 -1
View File
@@ -34,6 +34,9 @@ pub enum FileUploadTarget {
/// The server writes the file to this path (created or truncated) /// The server writes the file to this path (created or truncated)
/// and reports the result on `result_tx`. /// 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 { Path {
/// The path to write the file to. /// The path to write the file to.
path: PathBuf, path: PathBuf,
@@ -49,6 +52,9 @@ pub enum FileUploadTarget {
/// The server writes the file to this raw file descriptor (Android only) /// The server writes the file to this raw file descriptor (Android only)
/// and reports the result on `result_tx`. /// 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")] #[cfg(target_os = "android")]
Fd { Fd {
/// The raw file descriptor to write the file to. /// 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. /// Outcome of receiving an uploaded file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SaveResult { pub(crate) enum SaveResult {
@@ -84,6 +104,7 @@ pub(crate) async fn save_req_to_target(
target: FileUploadTarget, target: FileUploadTarget,
file_size: u64, file_size: u64,
expected_sha256: Option<&str>, expected_sha256: Option<&str>,
timestamps: FileTimestamps,
) -> SaveResult { ) -> SaveResult {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -109,6 +130,7 @@ pub(crate) async fn save_req_to_target(
}, },
file_size, file_size,
progress_tx, progress_tx,
timestamps,
); );
(binary_tx, result_rx, Some(result_tx)) (binary_tx, result_rx, Some(result_tx))
} }
@@ -129,6 +151,7 @@ pub(crate) async fn save_req_to_target(
}, },
file_size, file_size,
progress_tx, progress_tx,
timestamps,
); );
(binary_tx, result_rx, Some(result_tx)) (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, open: impl Future<Output = Result<tokio::fs::File, String>> + Send + 'static,
expected_size: u64, expected_size: u64,
progress_tx: Option<mpsc::Sender<u64>>, progress_tx: Option<mpsc::Sender<u64>>,
timestamps: FileTimestamps,
) -> (mpsc::Sender<Bytes>, oneshot::Receiver<Result<(), String>>) { ) -> (mpsc::Sender<Bytes>, oneshot::Receiver<Result<(), String>>) {
let (binary_tx, mut binary_rx) = mpsc::channel::<Bytes>(UPLOAD_CHANNEL_CAPACITY); let (binary_tx, mut binary_rx) = mpsc::channel::<Bytes>(UPLOAD_CHANNEL_CAPACITY);
let (internal_tx, internal_rx) = oneshot::channel::<Result<(), String>>(); let (internal_tx, internal_rx) = oneshot::channel::<Result<(), String>>();
tokio::spawn(async move { tokio::spawn(async move {
let result = 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. // Unblock the request handler if it is still sending chunks.
binary_rx.close(); binary_rx.close();
let _ = internal_tx.send(result); 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 /// 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. /// 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( async fn write_file_from_receiver(
open: impl Future<Output = Result<tokio::fs::File, String>>, open: impl Future<Output = Result<tokio::fs::File, String>>,
expected_size: u64, expected_size: u64,
rx: &mut mpsc::Receiver<Bytes>, rx: &mut mpsc::Receiver<Bytes>,
progress_tx: Option<mpsc::Sender<u64>>, progress_tx: Option<mpsc::Sender<u64>>,
timestamps: FileTimestamps,
) -> Result<(), String> { ) -> Result<(), String> {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -295,5 +325,26 @@ async fn write_file_from_receiver(
tracing::warn!("Could not truncate file to {written} bytes: {e}"); 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(()) Ok(())
} }
+16 -3
View File
@@ -7,7 +7,7 @@ use crate::http::server::common::error::AppError;
use crate::http::server::common::pin::check_pin; use crate::http::server::common::pin::check_pin;
use crate::http::server::common::query::parse_query; use crate::http::server::common::query::parse_query;
use crate::http::server::common::response::{empty_body, BoxedBody, JsonResponse}; 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::{ use crate::http::server::common::session::{
FileStatusV2, PendingSessionV2, SessionFileV2, SessionStateV2, UploadSessionV2, FileStatusV2, PendingSessionV2, SessionFileV2, SessionStateV2, UploadSessionV2,
}; };
@@ -385,6 +385,13 @@ pub(crate) async fn upload(
let file_size = file_dto.size; let file_size = file_dto.size;
let expected_sha256 = file_dto.sha256.clone(); 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 (target_tx, target_rx) = oneshot::channel::<FileUploadTarget>();
let event = ServerEventV2::FileUpload { let event = ServerEventV2::FileUpload {
@@ -403,8 +410,14 @@ pub(crate) async fn upload(
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR)); return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}; };
let result = let result = common::save::save_req_to_target(
common::save::save_req_to_target(req, target, file_size, expected_sha256.as_deref()).await; req,
target,
file_size,
expected_sha256.as_deref(),
timestamps,
)
.await;
upload_guard.finish(result).await; upload_guard.finish(result).await;
+83
View File
@@ -115,3 +115,86 @@ pub struct FileMetadata {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub accessed: Option<String>, 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);
}
}
+56 -1
View File
@@ -10,7 +10,7 @@ use localsend::http::server::common::save::FileUploadTarget;
use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2, SessionEndReasonV2}; use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2, SessionEndReasonV2};
use localsend::http::server::{start_with_port, ServerConfigV2}; use localsend::http::server::{start_with_port, ServerConfigV2};
use localsend::http::state::ClientInfo; use localsend::http::state::ClientInfo;
use localsend::model::transfer::FileDto; use localsend::model::transfer::{FileDto, FileMetadata};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU16, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU16, AtomicU64, Ordering};
@@ -370,6 +370,61 @@ async fn test_full_upload_flow() {
assert_status(result, 403); 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] #[tokio::test]
async fn test_upload_with_matching_sha256() { async fn test_upload_with_matching_sha256() {
let server = start_test_server(None, true, None).await; 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] /// The progress (fraction of [file_size]) is emitted on [sink]
/// while the file is being received. /// 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}); Stream<double> respondFileUpload({required String sessionId, required String fileId, String? path, int? fileDescriptor, required BigInt fileSize});
/// Answers the pending [RsServerEvent::WebPrepareDownload] event. /// 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] /// The progress (fraction of [file_size]) is emitted on [sink]
/// while the file is being received. /// 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({ Stream<double> respondFileUpload({
required String sessionId, required String sessionId,
required String fileId, required String fileId,
@@ -683,12 +683,6 @@ Future<void> _handleFileUpload({
} }
try { try {
await applyFileTimestamps(
target: target,
lastModified: dartFile.metadata?.lastModified,
lastAccessed: dartFile.metadata?.lastAccessed,
);
String? filePath; String? filePath;
bool savedToGallery = false; bool savedToGallery = false;
if (shouldSaveToGallery) { 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 /// Moves a file that has been written to the cache directory into the
/// OS gallery (Photos/Videos). /// OS gallery (Photos/Videos).
/// ///
@@ -57,6 +57,9 @@ class HttpServerService {
/// The returned stream emits the progress (fraction of [fileSize]) while the /// The returned stream emits the progress (fraction of [fileSize]) while the
/// file is being received and closes once the file has been received /// file is being received and closes once the file has been received
/// completely (or errors when saving failed). /// 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({ Stream<double> respondFileUpload({
required String sessionId, required String sessionId,
required String fileId, required String fileId,
+2 -2
View File
@@ -640,10 +640,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.10"
type_plus: type_plus:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -1594,6 +1594,7 @@ dependencies = [
"sha2 0.11.0", "sha2 0.11.0",
"socket2 0.6.5", "socket2 0.6.5",
"thiserror 2.0.19", "thiserror 2.0.19",
"time",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-stream", "tokio-stream",
@@ -388,6 +388,9 @@ impl RsHttpServer {
/// ///
/// The progress (fraction of [file_size]) is emitted on [sink] /// The progress (fraction of [file_size]) is emitted on [sink]
/// while the file is being received. /// 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( pub async fn respond_file_upload(
&self, &self,
sink: StreamSink<f64>, sink: StreamSink<f64>,