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",
"socket2 0.6.5",
"thiserror 2.0.19",
"time",
"tokio",
"tokio-rustls",
"tokio-stream",
+1
View File
@@ -1403,6 +1403,7 @@ dependencies = [
"sha2 0.11.0",
"socket2 0.6.5",
"thiserror 2.0.19",
"time",
"tokio",
"tokio-rustls",
"tokio-stream",
+2 -1
View File
@@ -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"]
+52 -1
View File
@@ -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(())
}
+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::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;
+83
View File
@@ -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);
}
}
+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::{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,
+2 -2
View File
@@ -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
View File
@@ -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>,