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}