|
4 | 4 | //! maintains an active MTP session until disconnected or unplugged. |
5 | 5 |
|
6 | 6 | use log::{debug, error, info, warn}; |
7 | | -use mtp_rs::{MtpDevice, MtpDeviceBuilder}; |
| 7 | +use mtp_rs::{MtpDevice, MtpDeviceBuilder, ObjectHandle, StorageId}; |
8 | 8 | use std::collections::HashMap; |
9 | | -use std::sync::{LazyLock, Mutex}; |
| 9 | +use std::path::{Path, PathBuf}; |
| 10 | +use std::sync::{Arc, LazyLock, Mutex, RwLock}; |
10 | 11 | use std::time::Duration; |
11 | 12 | use tauri::{AppHandle, Emitter}; |
12 | 13 |
|
13 | 14 | use super::types::{MtpDeviceInfo, MtpStorageInfo}; |
| 15 | +use crate::file_system::FileEntry; |
14 | 16 |
|
15 | 17 | /// Default timeout for MTP operations (30 seconds - some devices are slow). |
16 | 18 | const MTP_TIMEOUT_SECS: u64 = 30; |
@@ -92,12 +94,21 @@ pub struct ConnectedDeviceInfo { |
92 | 94 |
|
93 | 95 | /// Internal entry for a connected device. |
94 | 96 | struct DeviceEntry { |
95 | | - /// The MTP device handle. |
96 | | - device: MtpDevice, |
| 97 | + /// The MTP device handle (wrapped in Arc for shared access). |
| 98 | + device: Arc<tokio::sync::Mutex<MtpDevice>>, |
97 | 99 | /// Device metadata. |
98 | 100 | info: MtpDeviceInfo, |
99 | 101 | /// Cached storage information. |
100 | 102 | storages: Vec<MtpStorageInfo>, |
| 103 | + /// Path-to-handle cache per storage. |
| 104 | + path_cache: RwLock<HashMap<u32, PathHandleCache>>, |
| 105 | +} |
| 106 | + |
| 107 | +/// Cache for mapping paths to MTP object handles. |
| 108 | +#[derive(Default)] |
| 109 | +struct PathHandleCache { |
| 110 | + /// Maps virtual path -> MTP object handle. |
| 111 | + path_to_handle: HashMap<PathBuf, ObjectHandle>, |
101 | 112 | } |
102 | 113 |
|
103 | 114 | /// Global connection manager for MTP devices. |
@@ -215,15 +226,16 @@ impl MtpConnectionManager { |
215 | 226 | storages: storages.clone(), |
216 | 227 | }; |
217 | 228 |
|
218 | | - // Store in registry |
| 229 | + // Store in registry with Arc-wrapped device for shared access |
219 | 230 | { |
220 | 231 | let mut devices = self.devices.lock().unwrap(); |
221 | 232 | devices.insert( |
222 | 233 | device_id.to_string(), |
223 | 234 | DeviceEntry { |
224 | | - device, |
| 235 | + device: Arc::new(tokio::sync::Mutex::new(device)), |
225 | 236 | info: device_info, |
226 | 237 | storages, |
| 238 | + path_cache: RwLock::new(HashMap::new()), |
227 | 239 | }, |
228 | 240 | ); |
229 | 241 | } |
@@ -266,11 +278,12 @@ impl MtpConnectionManager { |
266 | 278 | }); |
267 | 279 | }; |
268 | 280 |
|
269 | | - // Close the device gracefully |
270 | | - if let Err(e) = entry.device.close().await { |
271 | | - warn!("Error closing MTP device {}: {:?}", device_id, e); |
272 | | - // Continue anyway - device might have been unplugged |
273 | | - } |
| 281 | + // The device will be closed when it's dropped. |
| 282 | + // MtpDevice::close() takes ownership, but we have it in an Arc<Mutex>. |
| 283 | + // Dropping the entry will drop the Arc, and if this is the last reference, |
| 284 | + // the device will be closed (MtpDevice has a Drop impl that closes the session). |
| 285 | + // We just drop the entry here - the device handle going out of scope handles cleanup. |
| 286 | + drop(entry); |
274 | 287 |
|
275 | 288 | // Emit disconnected event |
276 | 289 | if let Some(app) = app { |
@@ -310,6 +323,169 @@ impl MtpConnectionManager { |
310 | 323 | devices.keys().cloned().collect() |
311 | 324 | } |
312 | 325 |
|
| 326 | + /// Lists the contents of a directory on an MTP device. |
| 327 | + /// |
| 328 | + /// # Arguments |
| 329 | + /// |
| 330 | + /// * `device_id` - The connected device ID |
| 331 | + /// * `storage_id` - The storage ID within the device |
| 332 | + /// * `path` - Virtual path to list (for example, "/" or "/DCIM") |
| 333 | + /// |
| 334 | + /// # Returns |
| 335 | + /// |
| 336 | + /// A vector of FileEntry objects suitable for the file browser. |
| 337 | + pub async fn list_directory( |
| 338 | + &self, |
| 339 | + device_id: &str, |
| 340 | + storage_id: u32, |
| 341 | + path: &str, |
| 342 | + ) -> Result<Vec<FileEntry>, MtpConnectionError> { |
| 343 | + debug!( |
| 344 | + "MTP list_directory: device={}, storage={}, path={}", |
| 345 | + device_id, storage_id, path |
| 346 | + ); |
| 347 | + |
| 348 | + // Get the device and resolve path to handle |
| 349 | + let (device_arc, parent_handle) = { |
| 350 | + let devices = self.devices.lock().unwrap(); |
| 351 | + let entry = devices.get(device_id).ok_or_else(|| MtpConnectionError::NotConnected { |
| 352 | + device_id: device_id.to_string(), |
| 353 | + })?; |
| 354 | + |
| 355 | + // Resolve path to parent handle |
| 356 | + let parent_handle = self.resolve_path_to_handle(entry, storage_id, path)?; |
| 357 | + |
| 358 | + (Arc::clone(&entry.device), parent_handle) |
| 359 | + }; |
| 360 | + |
| 361 | + // Normalize the path for building child paths |
| 362 | + let parent_path = normalize_mtp_path(path); |
| 363 | + |
| 364 | + // List directory contents (async operation) |
| 365 | + let device = device_arc.lock().await; |
| 366 | + |
| 367 | + // Get the storage object |
| 368 | + let storage = tokio::time::timeout( |
| 369 | + Duration::from_secs(MTP_TIMEOUT_SECS), |
| 370 | + device.storage(StorageId(storage_id)), |
| 371 | + ) |
| 372 | + .await |
| 373 | + .map_err(|_| MtpConnectionError::Timeout { |
| 374 | + device_id: device_id.to_string(), |
| 375 | + })? |
| 376 | + .map_err(|e| map_mtp_error(e, device_id))?; |
| 377 | + |
| 378 | + // Use list_objects which returns Vec<ObjectInfo> directly |
| 379 | + let parent_opt = if parent_handle == ObjectHandle::ROOT { |
| 380 | + None |
| 381 | + } else { |
| 382 | + Some(parent_handle) |
| 383 | + }; |
| 384 | + |
| 385 | + let object_infos = |
| 386 | + tokio::time::timeout(Duration::from_secs(MTP_TIMEOUT_SECS), storage.list_objects(parent_opt)) |
| 387 | + .await |
| 388 | + .map_err(|_| MtpConnectionError::Timeout { |
| 389 | + device_id: device_id.to_string(), |
| 390 | + })? |
| 391 | + .map_err(|e| map_mtp_error(e, device_id))?; |
| 392 | + |
| 393 | + debug!("MTP list_directory: found {} objects", object_infos.len()); |
| 394 | + |
| 395 | + let mut entries = Vec::with_capacity(object_infos.len()); |
| 396 | + let mut cache_updates: Vec<(PathBuf, ObjectHandle)> = Vec::new(); |
| 397 | + |
| 398 | + for info in object_infos { |
| 399 | + let is_dir = info.format == mtp_rs::ptp::ObjectFormatCode::Association; |
| 400 | + let child_path = parent_path.join(&info.filename); |
| 401 | + |
| 402 | + // Queue cache update |
| 403 | + cache_updates.push((child_path.clone(), info.handle)); |
| 404 | + |
| 405 | + // Convert MTP timestamps |
| 406 | + let modified_at = info.modified.map(convert_mtp_datetime); |
| 407 | + let created_at = info.created.map(convert_mtp_datetime); |
| 408 | + |
| 409 | + entries.push(FileEntry { |
| 410 | + name: info.filename.clone(), |
| 411 | + path: child_path.to_string_lossy().to_string(), |
| 412 | + is_directory: is_dir, |
| 413 | + is_symlink: false, |
| 414 | + size: if is_dir { None } else { Some(info.size) }, |
| 415 | + modified_at, |
| 416 | + created_at, |
| 417 | + added_at: None, |
| 418 | + opened_at: None, |
| 419 | + permissions: if is_dir { 0o755 } else { 0o644 }, |
| 420 | + owner: String::new(), |
| 421 | + group: String::new(), |
| 422 | + icon_id: get_mtp_icon_id(is_dir, &info.filename), |
| 423 | + extended_metadata_loaded: true, |
| 424 | + }); |
| 425 | + } |
| 426 | + |
| 427 | + // Release device lock before updating cache |
| 428 | + drop(storage); |
| 429 | + drop(device); |
| 430 | + |
| 431 | + // Update path cache |
| 432 | + { |
| 433 | + let devices = self.devices.lock().unwrap(); |
| 434 | + if let Some(entry) = devices.get(device_id) |
| 435 | + && let Ok(mut cache_map) = entry.path_cache.write() |
| 436 | + { |
| 437 | + let storage_cache = cache_map.entry(storage_id).or_default(); |
| 438 | + for (path, handle) in cache_updates { |
| 439 | + storage_cache.path_to_handle.insert(path, handle); |
| 440 | + } |
| 441 | + } |
| 442 | + } |
| 443 | + |
| 444 | + // Sort: directories first, then files, both alphabetically |
| 445 | + entries.sort_by(|a, b| match (a.is_directory, b.is_directory) { |
| 446 | + (true, false) => std::cmp::Ordering::Less, |
| 447 | + (false, true) => std::cmp::Ordering::Greater, |
| 448 | + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), |
| 449 | + }); |
| 450 | + |
| 451 | + debug!("MTP list_directory: returning {} entries", entries.len()); |
| 452 | + Ok(entries) |
| 453 | + } |
| 454 | + |
| 455 | + /// Resolves a virtual path to an MTP object handle. |
| 456 | + fn resolve_path_to_handle( |
| 457 | + &self, |
| 458 | + entry: &DeviceEntry, |
| 459 | + storage_id: u32, |
| 460 | + path: &str, |
| 461 | + ) -> Result<ObjectHandle, MtpConnectionError> { |
| 462 | + let path = normalize_mtp_path(path); |
| 463 | + |
| 464 | + // Root is always ObjectHandle::ROOT |
| 465 | + if path.as_os_str() == "/" || path.as_os_str().is_empty() { |
| 466 | + return Ok(ObjectHandle::ROOT); |
| 467 | + } |
| 468 | + |
| 469 | + // Check cache |
| 470 | + if let Ok(cache_map) = entry.path_cache.read() |
| 471 | + && let Some(storage_cache) = cache_map.get(&storage_id) |
| 472 | + && let Some(handle) = storage_cache.path_to_handle.get(&path) |
| 473 | + { |
| 474 | + return Ok(*handle); |
| 475 | + } |
| 476 | + |
| 477 | + // Path not in cache - we need to traverse |
| 478 | + // For Phase 3, we'll only support navigating to paths that have been listed |
| 479 | + // (the cache is populated as directories are browsed) |
| 480 | + Err(MtpConnectionError::Other { |
| 481 | + device_id: entry.info.id.clone(), |
| 482 | + message: format!( |
| 483 | + "Path not in cache: {}. Navigate through parent directories first.", |
| 484 | + path.display() |
| 485 | + ), |
| 486 | + }) |
| 487 | + } |
| 488 | + |
313 | 489 | /// Handles a device disconnection (called when we detect the device was unplugged). |
314 | 490 | #[allow(dead_code, reason = "Will be used in Phase 5 for USB hotplug detection")] |
315 | 491 | pub fn handle_device_disconnected(&self, device_id: &str, app: Option<&AppHandle>) { |
@@ -407,6 +583,54 @@ fn map_mtp_error(e: mtp_rs::Error, device_id: &str) -> MtpConnectionError { |
407 | 583 | } |
408 | 584 | } |
409 | 585 |
|
| 586 | +/// Normalizes an MTP path. |
| 587 | +/// |
| 588 | +/// Ensures the path starts with "/" and handles empty/relative paths. |
| 589 | +fn normalize_mtp_path(path: &str) -> PathBuf { |
| 590 | + if path.is_empty() || path == "." { |
| 591 | + PathBuf::from("/") |
| 592 | + } else if !path.starts_with('/') { |
| 593 | + PathBuf::from("/").join(path) |
| 594 | + } else { |
| 595 | + PathBuf::from(path) |
| 596 | + } |
| 597 | +} |
| 598 | + |
| 599 | +/// Converts MTP DateTime to Unix timestamp. |
| 600 | +fn convert_mtp_datetime(dt: mtp_rs::ptp::DateTime) -> u64 { |
| 601 | + // Convert the DateTime struct fields to Unix timestamp |
| 602 | + // This is a simplified conversion - MTP DateTime has year, month, day, hour, minute, second |
| 603 | + |
| 604 | + // Create a rough Unix timestamp from the date components |
| 605 | + // Note: This is a simplified calculation that doesn't account for leap years perfectly |
| 606 | + let year = dt.year as u64; |
| 607 | + let month = dt.month as u64; |
| 608 | + let day = dt.day as u64; |
| 609 | + let hour = dt.hour as u64; |
| 610 | + let minute = dt.minute as u64; |
| 611 | + let second = dt.second as u64; |
| 612 | + |
| 613 | + // Simplified calculation: days since epoch + time |
| 614 | + // This is approximate but good enough for file listing purposes |
| 615 | + let years_since_1970 = year.saturating_sub(1970); |
| 616 | + let days = years_since_1970 * 365 + (years_since_1970 / 4) // leap years approximation |
| 617 | + + (month.saturating_sub(1)) * 30 // approximate days per month |
| 618 | + + day.saturating_sub(1); |
| 619 | + |
| 620 | + days * 86400 + hour * 3600 + minute * 60 + second |
| 621 | +} |
| 622 | + |
| 623 | +/// Generates icon ID for MTP files. |
| 624 | +fn get_mtp_icon_id(is_dir: bool, filename: &str) -> String { |
| 625 | + if is_dir { |
| 626 | + return "dir".to_string(); |
| 627 | + } |
| 628 | + if let Some(ext) = Path::new(filename).extension() { |
| 629 | + return format!("ext:{}", ext.to_string_lossy().to_lowercase()); |
| 630 | + } |
| 631 | + "file".to_string() |
| 632 | +} |
| 633 | + |
410 | 634 | #[cfg(test)] |
411 | 635 | mod tests { |
412 | 636 | use super::*; |
|
0 commit comments