Skip to main content

trash_core/
android.rs

1//! Read-only decoder for the Android **`MediaStore` trash** filename convention.
2//!
3//! On Android 11+ (API 30) the system-level, vendor-independent trash renames a
4//! media file *in place* to a self-describing hidden name:
5//!
6//! ```text
7//! .trashed-<dateExpires>-<originalDisplayName>
8//! ```
9//!
10//! with a 7-day sibling mechanism using the `pending` prefix. Because the
11//! `MediaProvider` rebuilds its `files`-table row *from this name* on rescan, the
12//! name alone recovers the original filename and the expiry time **even if the
13//! database is wiped**. This module decodes that name; correlating it with the
14//! `external.db` `files` table is a separate (`SQLite`) concern.
15//!
16//! # Codec (authoritative)
17//!
18//! AOSP `packages/providers/MediaProvider` `util/FileUtils.java`
19//! (<https://android.googlesource.com/platform/packages/providers/MediaProvider/+/refs/heads/android11-release/src/com/android/providers/media/util/FileUtils.java>):
20//!
21//! ```text
22//! PATTERN_EXPIRES_FILE = (?i)^\.(pending|trashed)-(\d+)-([^/]+)$
23//! DEFAULT_DURATION_TRASHED = 30 days,  DEFAULT_DURATION_PENDING = 7 days
24//! ```
25//!
26//! * `<dateExpires>` is **epoch SECONDS** (the source divides milliseconds by
27//!   1000), not milliseconds.
28//! * `<originalDisplayName>` is the original filename including extension; it may
29//!   itself contain `-` and `.`, so it is everything after the *second* `-`.
30//! * The prefix match is case-insensitive; the display name keeps its case.
31
32use chrono::{DateTime, TimeZone, Utc};
33
34/// Whether a `MediaStore` name encodes the 30-day **trashed** state or the 7-day
35/// **pending** state.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub enum TrashState {
39    /// `.trashed-…` — the 30-day deferred-deletion bin.
40    Trashed,
41    /// `.pending-…` — the 7-day pending mechanism.
42    Pending,
43}
44
45impl TrashState {
46    /// The default retention window AOSP applies for this state, in seconds
47    /// (30 days trashed, 7 days pending).
48    #[must_use]
49    pub fn default_retention_secs(self) -> i64 {
50        const SECONDS_PER_DAY: i64 = 86_400;
51        match self {
52            TrashState::Trashed => 30 * SECONDS_PER_DAY,
53            TrashState::Pending => 7 * SECONDS_PER_DAY,
54        }
55    }
56}
57
58/// A decoded `MediaStore` `.trashed-`/`.pending-` filename.
59#[derive(Debug, Clone, PartialEq, Eq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub struct TrashedName {
62    /// Trashed (30-day) vs pending (7-day).
63    pub state: TrashState,
64    /// The `dateExpires` field, as stored: **epoch seconds**.
65    pub date_expires: i64,
66    /// The original display name (filename incl. extension), case preserved.
67    pub original_name: String,
68}
69
70impl TrashedName {
71    /// The expiry instant encoded in the name (`dateExpires`), or `None` if the
72    /// value is outside the representable range.
73    #[must_use]
74    pub fn expires_at(&self) -> Option<DateTime<Utc>> {
75        Utc.timestamp_opt(self.date_expires, 0).single()
76    }
77
78    /// The **inferred** deletion instant: `dateExpires` minus the state's default
79    /// retention window. This holds only for the default-case (the user did not
80    /// override the duration), so it is an inference, not a recorded fact.
81    #[must_use]
82    pub fn inferred_deleted_at(&self) -> Option<DateTime<Utc>> {
83        let secs = self
84            .date_expires
85            .checked_sub(self.state.default_retention_secs())?;
86        Utc.timestamp_opt(secs, 0).single()
87    }
88}
89
90/// Decode a single filename per AOSP `PATTERN_EXPIRES_FILE`. Returns `None` for
91/// any name that is not a well-formed `.trashed-`/`.pending-` token (including a
92/// plain, non-trashed filename) — the caller decides whether a `None` that still
93/// carries a trashed/pending prefix is a malformed-token anomaly.
94#[must_use]
95pub fn parse_trashed_name(name: &str) -> Option<TrashedName> {
96    let rest = name.strip_prefix('.')?;
97    let (state, after) = strip_state_prefix(rest)?;
98    // The display name is everything after the SECOND `-`, so split on the first
99    // `-` of `after` (which holds `<digits>-<name>`).
100    let (digits, original) = after.split_once('-')?;
101    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
102        return None;
103    }
104    let date_expires = digits.parse::<i64>().ok()?;
105    if original.is_empty() || original.contains('/') {
106        return None;
107    }
108    Some(TrashedName {
109        state,
110        date_expires,
111        original_name: original.to_string(),
112    })
113}
114
115/// Strip a case-insensitive `trashed-`/`pending-` prefix, returning the state and
116/// the remainder. Boundary-safe: a leading multi-byte character yields `None`.
117fn strip_state_prefix(rest: &str) -> Option<(TrashState, &str)> {
118    for (prefix, state) in [
119        ("trashed-", TrashState::Trashed),
120        ("pending-", TrashState::Pending),
121    ] {
122        let Some(head) = rest.get(..prefix.len()) else {
123            continue;
124        };
125        if head.eq_ignore_ascii_case(prefix) {
126            return rest.get(prefix.len()..).map(|tail| (state, tail));
127        }
128    }
129    None
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn at(secs: i64) -> DateTime<Utc> {
137        Utc.timestamp_opt(secs, 0).single().unwrap()
138    }
139
140    /// The canonical trashed form decodes to state, epoch-seconds expiry, and name.
141    #[test]
142    fn decodes_trashed() {
143        let t = parse_trashed_name(".trashed-1700000000-photo.jpg").unwrap();
144        assert_eq!(t.state, TrashState::Trashed);
145        assert_eq!(t.date_expires, 1_700_000_000);
146        assert_eq!(t.original_name, "photo.jpg");
147    }
148
149    /// The 7-day sibling uses the `pending` prefix.
150    #[test]
151    fn decodes_pending() {
152        let t = parse_trashed_name(".pending-1700000000-clip.mp4").unwrap();
153        assert_eq!(t.state, TrashState::Pending);
154    }
155
156    /// The original name may contain `-` and `.`: split on the SECOND `-` only.
157    #[test]
158    fn original_name_keeps_dashes_and_dots() {
159        let t = parse_trashed_name(".trashed-1700000000-my-holiday.2024-04.jpg").unwrap();
160        assert_eq!(t.original_name, "my-holiday.2024-04.jpg");
161    }
162
163    /// The prefix is matched case-insensitively; the name keeps its case.
164    #[test]
165    fn prefix_is_case_insensitive() {
166        let t = parse_trashed_name(".TrAsHeD-1700000000-IMG_0001.HEIC").unwrap();
167        assert_eq!(t.state, TrashState::Trashed);
168        assert_eq!(t.original_name, "IMG_0001.HEIC");
169    }
170
171    /// Non-trashed names are not decoded.
172    #[test]
173    fn ignores_non_trashed_names() {
174        assert!(parse_trashed_name("photo.jpg").is_none());
175        assert!(parse_trashed_name(".hidden").is_none());
176        assert!(parse_trashed_name("invoice-2024-final.pdf").is_none());
177    }
178
179    /// A trashed prefix with a non-numeric expiry is not a valid token.
180    #[test]
181    fn rejects_non_numeric_expiry() {
182        assert!(parse_trashed_name(".trashed-abc-photo.jpg").is_none());
183    }
184
185    /// A display name containing `/` is rejected (the codec is `[^/]+`).
186    #[test]
187    fn rejects_slash_in_name() {
188        assert!(parse_trashed_name(".trashed-1700000000-a/b.jpg").is_none());
189    }
190
191    /// A missing expiry or missing name is rejected.
192    #[test]
193    fn rejects_missing_fields() {
194        assert!(parse_trashed_name(".trashed-1700000000-").is_none());
195        assert!(parse_trashed_name(".trashed--photo.jpg").is_none());
196    }
197
198    /// `expires_at` is the encoded instant; `inferred_deleted_at` subtracts the
199    /// 30-day default window for a trashed item.
200    #[test]
201    fn timestamps_decode_and_infer() {
202        let t = parse_trashed_name(".trashed-1700000000-photo.jpg").unwrap();
203        assert_eq!(t.expires_at(), Some(at(1_700_000_000)));
204        assert_eq!(
205            t.inferred_deleted_at(),
206            Some(at(1_700_000_000 - 30 * 86_400))
207        );
208    }
209
210    /// The pending state uses a 7-day retention window for the inferred deletion.
211    #[test]
212    fn pending_retention_and_inference() {
213        let t = parse_trashed_name(".pending-1700000000-x.tmp").unwrap();
214        assert_eq!(t.state.default_retention_secs(), 7 * 86_400);
215        assert_eq!(
216            t.inferred_deleted_at(),
217            Some(at(1_700_000_000 - 7 * 86_400))
218        );
219    }
220}