Skip to main content

cranpose_services/
content.rs

1//! Streaming content: the single shape every byte source hands the application.
2//!
3//! File picking, incoming shares, document intents, dropped files and media
4//! sources all resolve to a [`ContentHandle`]. A handle carries
5//! [`ContentMetadata`] and opens a [`ContentReader`] that yields chunks, so a
6//! multi-gigabyte media file and a two-byte clipboard drop travel the same API
7//! and neither forces the application to buffer a whole payload.
8//!
9//! Folders resolve to a [`ContentFolderRef`]. Their trees are consumed through
10//! [`folder_files`], which returns a [`ContentStream`]: an asynchronous stream
11//! whose `next` future wakes its collector when the provider discovers another
12//! file. Nothing here polls.
13
14use std::{
15    cell::RefCell,
16    collections::VecDeque,
17    future::Future,
18    pin::Pin,
19    rc::Rc,
20    task::{Context, Poll, Waker},
21};
22
23/// Chunk size the framework's own readers hand back per [`ContentReader::read_chunk`].
24pub const DEFAULT_CHUNK_LEN: usize = 256 * 1024;
25
26/// A future produced by a content operation. It borrows the content it reads
27/// from, so no implementation has to clone a handle into every call.
28pub type ContentFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
29
30/// Errors produced while reading or writing content.
31#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
32pub enum ContentError {
33    /// The content no longer exists at its source.
34    #[error("content not found: {0}")]
35    NotFound(String),
36    /// The provider refused the read or write.
37    #[error("content access denied: {0}")]
38    PermissionDenied(String),
39    /// Any other provider failure.
40    #[error("content i/o failed: {0}")]
41    Io(String),
42    /// The operation is not offered by this platform or build.
43    #[error("{0} is not supported on this platform")]
44    Unsupported(&'static str),
45}
46
47/// Everything a provider can state about a piece of content without reading it.
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
49pub struct ContentMetadata {
50    /// Display name, usually the last path or URI component.
51    pub name: String,
52    /// MIME type when the provider reports one.
53    pub mime_type: Option<String>,
54    /// Byte length when the provider reports one.
55    pub len: Option<u64>,
56    /// Last-modified time in milliseconds since the Unix epoch, when known.
57    pub modified_millis: Option<u64>,
58    /// Provider-scoped identifier: a path, a `content://` URI, a blob name.
59    /// Not guaranteed to be openable outside its provider.
60    pub identifier: String,
61}
62
63impl ContentMetadata {
64    /// Metadata carrying only a display name.
65    pub fn named(name: impl Into<String>) -> Self {
66        let name = name.into();
67        Self {
68            identifier: name.clone(),
69            name,
70            ..Self::default()
71        }
72    }
73
74    /// Sets the MIME type.
75    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
76        self.mime_type = Some(mime_type.into());
77        self
78    }
79
80    /// Sets the byte length.
81    pub fn with_len(mut self, len: u64) -> Self {
82        self.len = Some(len);
83        self
84    }
85
86    /// Sets the last-modified time in milliseconds since the Unix epoch.
87    pub fn with_modified_millis(mut self, modified_millis: u64) -> Self {
88        self.modified_millis = Some(modified_millis);
89        self
90    }
91
92    /// Sets the provider-scoped identifier.
93    pub fn with_identifier(mut self, identifier: impl Into<String>) -> Self {
94        self.identifier = identifier.into();
95        self
96    }
97
98    /// The lowercase extension of [`name`](Self::name), without the dot.
99    pub fn extension(&self) -> Option<String> {
100        let (_, extension) = self.name.rsplit_once('.')?;
101        if extension.is_empty() {
102            None
103        } else {
104            Some(extension.to_ascii_lowercase())
105        }
106    }
107}
108
109/// A pull reader over one piece of content.
110///
111/// Each [`read_chunk`](ContentReader::read_chunk) resolves to the next chunk of
112/// bytes, or `None` once the content is exhausted. Chunk sizes are chosen by the
113/// provider; framework readers use [`DEFAULT_CHUNK_LEN`].
114pub trait ContentReader {
115    /// Resolves to the next chunk, or `None` at end of content.
116    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>>;
117}
118
119/// Shared handle to a [`ContentReader`].
120pub type ContentReaderRef = Rc<dyn ContentReader>;
121
122/// One readable piece of content from any source.
123pub trait Content {
124    /// What the provider states about this content without reading it.
125    fn metadata(&self) -> ContentMetadata;
126
127    /// Opens a chunked reader over the bytes.
128    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>>;
129
130    /// Reads every byte. The default drains [`open`](Content::open); providers
131    /// already holding the bytes override it to avoid the copy.
132    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
133        Box::pin(async move {
134            let reader = self.open().await?;
135            drain_reader(&reader).await
136        })
137    }
138}
139
140/// Shared handle to a [`Content`].
141pub type ContentHandle = Rc<dyn Content>;
142
143/// Reads a reader to its end.
144pub async fn drain_reader(reader: &ContentReaderRef) -> Result<Vec<u8>, ContentError> {
145    let mut bytes = Vec::new();
146    while let Some(chunk) = reader.read_chunk().await? {
147        bytes.extend_from_slice(&chunk);
148    }
149    Ok(bytes)
150}
151
152/// A writable destination for streamed content.
153pub trait ContentSink {
154    /// Appends `bytes` to the destination.
155    fn write_chunk(&self, bytes: Vec<u8>) -> ContentFuture<'_, Result<(), ContentError>>;
156
157    /// Commits the destination. Dropping a sink without finishing it discards
158    /// whatever the provider allows it to discard.
159    fn finish(&self) -> ContentFuture<'_, Result<(), ContentError>>;
160}
161
162/// Shared handle to a [`ContentSink`].
163pub type ContentSinkRef = Rc<dyn ContentSink>;
164
165/// Writes every byte of `bytes` into `sink` and commits it.
166pub async fn write_all(sink: &ContentSinkRef, bytes: Vec<u8>) -> Result<(), ContentError> {
167    sink.write_chunk(bytes).await?;
168    sink.finish().await
169}
170
171/// One child of a [`ContentFolder`].
172pub enum ContentEntry {
173    /// A readable file.
174    File(ContentHandle),
175    /// A nested folder.
176    Folder(ContentFolderRef),
177}
178
179impl ContentEntry {
180    /// The child's metadata regardless of kind.
181    pub fn metadata(&self) -> ContentMetadata {
182        match self {
183            ContentEntry::File(file) => file.metadata(),
184            ContentEntry::Folder(folder) => folder.metadata(),
185        }
186    }
187}
188
189/// A folder granted by a provider.
190pub trait ContentFolder {
191    /// What the provider states about this folder.
192    fn metadata(&self) -> ContentMetadata;
193
194    /// The immediate children.
195    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>>;
196
197    /// A provider-native stream over every file in the tree, when the provider
198    /// discovers files incrementally. Providers that enumerate eagerly return
199    /// `None` and [`folder_files`] walks them instead.
200    fn stream_files(&self) -> Option<ContentStreamRef> {
201        None
202    }
203}
204
205/// Shared handle to a [`ContentFolder`].
206pub type ContentFolderRef = Rc<dyn ContentFolder>;
207
208/// An asynchronous stream of content.
209///
210/// [`next`](ContentStream::next) resolves once the next item is available; the
211/// producer wakes the pending future, so collectors never poll.
212pub trait ContentStream {
213    /// Resolves to the next item, or `None` once the stream is exhausted.
214    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>>;
215
216    /// How many items the provider has produced so far, when it counts them.
217    fn produced(&self) -> Option<usize> {
218        None
219    }
220}
221
222/// Shared handle to a [`ContentStream`].
223pub type ContentStreamRef = Rc<dyn ContentStream>;
224
225/// Streams every file in `folder`'s tree, using the provider's native stream
226/// when it has one and a depth-first walk otherwise.
227pub fn folder_files(folder: ContentFolderRef) -> ContentStreamRef {
228    folder
229        .stream_files()
230        .unwrap_or_else(|| Rc::new(WalkStream::new(folder)))
231}
232
233/// Collects an entire stream. Convenient for tests and small trees; production
234/// collectors consume [`ContentStream::next`] incrementally.
235pub async fn collect_stream(stream: &ContentStreamRef) -> Result<Vec<ContentHandle>, ContentError> {
236    let mut items = Vec::new();
237    while let Some(item) = stream.next().await? {
238        items.push(item);
239    }
240    Ok(items)
241}
242
243struct WalkStream {
244    pending: RefCell<Vec<ContentFolderRef>>,
245    ready: RefCell<VecDeque<ContentHandle>>,
246    produced: std::cell::Cell<usize>,
247}
248
249impl WalkStream {
250    fn new(root: ContentFolderRef) -> Self {
251        Self {
252            pending: RefCell::new(vec![root]),
253            ready: RefCell::new(VecDeque::new()),
254            produced: std::cell::Cell::new(0),
255        }
256    }
257}
258
259impl ContentStream for WalkStream {
260    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
261        Box::pin(async move {
262            loop {
263                if let Some(file) = self.ready.borrow_mut().pop_front() {
264                    self.produced.set(self.produced.get() + 1);
265                    return Ok(Some(file));
266                }
267                let Some(folder) = self.pending.borrow_mut().pop() else {
268                    return Ok(None);
269                };
270                for entry in folder.entries().await? {
271                    match entry {
272                        ContentEntry::File(file) => self.ready.borrow_mut().push_back(file),
273                        ContentEntry::Folder(child) => self.pending.borrow_mut().push(child),
274                    }
275                }
276            }
277        })
278    }
279
280    fn produced(&self) -> Option<usize> {
281        Some(self.produced.get())
282    }
283}
284
285/// The producing half of a [`ContentStream`] fed by a platform callback.
286///
287/// A backend that receives items from outside the composition — an Android
288/// enumeration callback, a drop target, a download — pushes into the channel and
289/// the pending collector future is woken. This is the replacement for every
290/// "drain what is ready each frame" contract.
291pub struct ContentChannel {
292    shared: Rc<ChannelShared>,
293}
294
295struct ChannelShared {
296    ready: RefCell<VecDeque<ContentHandle>>,
297    error: RefCell<Option<ContentError>>,
298    closed: std::cell::Cell<bool>,
299    produced: std::cell::Cell<usize>,
300    waker: RefCell<Option<Waker>>,
301}
302
303impl ChannelShared {
304    fn wake(&self) {
305        if let Some(waker) = self.waker.borrow_mut().take() {
306            waker.wake();
307        }
308    }
309}
310
311impl Default for ContentChannel {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317impl ContentChannel {
318    /// Creates an open channel.
319    pub fn new() -> Self {
320        Self {
321            shared: Rc::new(ChannelShared {
322                ready: RefCell::new(VecDeque::new()),
323                error: RefCell::new(None),
324                closed: std::cell::Cell::new(false),
325                produced: std::cell::Cell::new(0),
326                waker: RefCell::new(None),
327            }),
328        }
329    }
330
331    /// The consuming half, handed to the application.
332    pub fn stream(&self) -> ContentStreamRef {
333        Rc::new(ChannelStream {
334            shared: Rc::clone(&self.shared),
335        })
336    }
337
338    /// Publishes one item and wakes a pending collector.
339    pub fn push(&self, content: ContentHandle) {
340        if self.shared.closed.get() {
341            return;
342        }
343        self.shared.ready.borrow_mut().push_back(content);
344        self.shared.wake();
345    }
346
347    /// Ends the stream with an error and wakes a pending collector.
348    pub fn fail(&self, error: ContentError) {
349        if self.shared.closed.get() {
350            return;
351        }
352        *self.shared.error.borrow_mut() = Some(error);
353        self.shared.closed.set(true);
354        self.shared.wake();
355    }
356
357    /// Ends the stream normally and wakes a pending collector.
358    pub fn close(&self) {
359        if self.shared.closed.get() {
360            return;
361        }
362        self.shared.closed.set(true);
363        self.shared.wake();
364    }
365
366    /// Whether the stream has been ended.
367    pub fn is_closed(&self) -> bool {
368        self.shared.closed.get()
369    }
370}
371
372struct ChannelStream {
373    shared: Rc<ChannelShared>,
374}
375
376impl ContentStream for ChannelStream {
377    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
378        Box::pin(ChannelNext {
379            shared: Rc::clone(&self.shared),
380        })
381    }
382
383    fn produced(&self) -> Option<usize> {
384        Some(self.shared.produced.get())
385    }
386}
387
388struct ChannelNext {
389    shared: Rc<ChannelShared>,
390}
391
392impl Future for ChannelNext {
393    type Output = Result<Option<ContentHandle>, ContentError>;
394
395    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
396        if let Some(item) = self.shared.ready.borrow_mut().pop_front() {
397            self.shared.produced.set(self.shared.produced.get() + 1);
398            return Poll::Ready(Ok(Some(item)));
399        }
400        if let Some(error) = self.shared.error.borrow_mut().take() {
401            return Poll::Ready(Err(error));
402        }
403        if self.shared.closed.get() {
404            return Poll::Ready(Ok(None));
405        }
406        *self.shared.waker.borrow_mut() = Some(context.waker().clone());
407        Poll::Pending
408    }
409}
410
411/// Content already held in memory: clipboard drops, web `File` payloads, tests.
412pub struct BytesContent {
413    metadata: ContentMetadata,
414    bytes: Rc<Vec<u8>>,
415}
416
417impl BytesContent {
418    /// Wraps `bytes` under `metadata`, filling in the byte length.
419    pub fn new(metadata: ContentMetadata, bytes: Vec<u8>) -> Self {
420        let len = bytes.len() as u64;
421        Self {
422            metadata: ContentMetadata {
423                len: Some(len),
424                ..metadata
425            },
426            bytes: Rc::new(bytes),
427        }
428    }
429
430    /// Wraps `bytes` under a display name.
431    pub fn named(name: impl Into<String>, bytes: Vec<u8>) -> Self {
432        Self::new(ContentMetadata::named(name), bytes)
433    }
434
435    /// A shared handle to this content.
436    pub fn handle(self) -> ContentHandle {
437        Rc::new(self)
438    }
439}
440
441impl Content for BytesContent {
442    fn metadata(&self) -> ContentMetadata {
443        self.metadata.clone()
444    }
445
446    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>> {
447        let bytes = Rc::clone(&self.bytes);
448        Box::pin(async move {
449            Ok(Rc::new(BytesReader {
450                bytes,
451                offset: std::cell::Cell::new(0),
452            }) as ContentReaderRef)
453        })
454    }
455
456    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
457        let bytes = Rc::clone(&self.bytes);
458        Box::pin(async move { Ok(bytes.as_ref().clone()) })
459    }
460}
461
462struct BytesReader {
463    bytes: Rc<Vec<u8>>,
464    offset: std::cell::Cell<usize>,
465}
466
467impl ContentReader for BytesReader {
468    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>> {
469        Box::pin(async move {
470            let start = self.offset.get();
471            if start >= self.bytes.len() {
472                return Ok(None);
473            }
474            let end = (start + DEFAULT_CHUNK_LEN).min(self.bytes.len());
475            self.offset.set(end);
476            Ok(Some(self.bytes[start..end].to_vec()))
477        })
478    }
479}
480
481/// A folder whose children are already known.
482pub struct ReadyFolder {
483    metadata: ContentMetadata,
484    entries: RefCell<Option<Vec<ContentEntry>>>,
485}
486
487impl ReadyFolder {
488    /// Wraps already-enumerated `entries` under `metadata`.
489    pub fn new(metadata: ContentMetadata, entries: Vec<ContentEntry>) -> Self {
490        Self {
491            metadata,
492            entries: RefCell::new(Some(entries)),
493        }
494    }
495
496    /// A shared handle to this folder.
497    pub fn handle(self) -> ContentFolderRef {
498        Rc::new(self)
499    }
500}
501
502impl ContentFolder for ReadyFolder {
503    fn metadata(&self) -> ContentMetadata {
504        self.metadata.clone()
505    }
506
507    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>> {
508        Box::pin(async move {
509            self.entries
510                .borrow_mut()
511                .take()
512                .ok_or_else(|| ContentError::Io("folder entries already consumed".into()))
513        })
514    }
515}
516
517/// Turns a provider URI into readable content.
518///
519/// Every source that names content by URI rather than handing over bytes —
520/// a document intent, a shared item, a dropped file, a media source — resolves
521/// through this, so applications never learn what a `content://`, `file://` or
522/// blob URI means on the current platform.
523pub trait ContentResolver {
524    /// Resolves `uri`, or `None` when this platform cannot open it.
525    fn resolve(&self, uri: &str) -> Option<ContentHandle>;
526}
527
528/// Shared handle to a [`ContentResolver`].
529pub type ContentResolverRef = Rc<dyn ContentResolver>;
530
531thread_local! {
532    static PLATFORM_RESOLVER: RefCell<Option<ContentResolverRef>> = const { RefCell::new(None) };
533}
534
535/// Installs the platform content resolver (Android's `ContentResolver`, the
536/// iOS document store, the browser's blob registry).
537pub fn set_platform_content_resolver(resolver: ContentResolverRef) {
538    PLATFORM_RESOLVER.with(|cell| *cell.borrow_mut() = Some(resolver));
539}
540
541/// Removes any installed platform content resolver.
542pub fn clear_platform_content_resolver() {
543    PLATFORM_RESOLVER.with(|cell| *cell.borrow_mut() = None);
544}
545
546/// Resolves `uri` through the platform resolver, falling back to the local
547/// filesystem for `file://` URIs and plain paths.
548pub fn resolve_content(uri: &str) -> Option<ContentHandle> {
549    if let Some(resolver) = PLATFORM_RESOLVER.with(|cell| cell.borrow().clone())
550        && let Some(content) = resolver.resolve(uri)
551    {
552        return Some(content);
553    }
554    #[cfg(not(target_arch = "wasm32"))]
555    {
556        let path = uri.strip_prefix("file://").unwrap_or(uri);
557        if !path.contains("://") {
558            return Some(file_content(path));
559        }
560    }
561    let _ = uri;
562    None
563}
564
565#[cfg(not(target_arch = "wasm32"))]
566pub use file::{FileContent, FileFolder, FileSink, file_content, file_folder};
567
568#[cfg(not(target_arch = "wasm32"))]
569mod file;
570
571/// Decodes the percent-escapes in a platform content URI, refusing input it
572/// cannot decode exactly.
573///
574/// Android hands a folder or document back as an opaque `content://` URI whose
575/// readable name is percent-encoded inside it, so anything that wants to show
576/// the user which folder they picked has to decode it. The pair here mirrors
577/// [`String::from_utf8`] and [`String::from_utf8_lossy`]: this one answers
578/// `None` rather than inventing a character, and [`percent_decode_lossy`] is
579/// the best-effort form for text that is only going to be displayed.
580///
581/// Prefer this when the result is used as an identity -- a key, a filename, a
582/// fingerprint -- where a replacement character would silently make two
583/// different inputs look the same.
584pub fn percent_decode(input: &str) -> Option<String> {
585    let bytes = input.as_bytes();
586    let mut out = Vec::with_capacity(bytes.len());
587    let mut index = 0;
588    while index < bytes.len() {
589        if bytes[index] == b'%' {
590            let high = bytes.get(index + 1).copied().and_then(hex_value)?;
591            let low = bytes.get(index + 2).copied().and_then(hex_value)?;
592            out.push((high << 4) | low);
593            index += 3;
594        } else {
595            out.push(bytes[index]);
596            index += 1;
597        }
598    }
599    String::from_utf8(out).ok()
600}
601
602/// Decodes the percent-escapes in a platform content URI for display.
603///
604/// A byte sequence that is not valid UTF-8 becomes U+FFFD, and an escape that
605/// is truncated or not hexadecimal is passed through as written rather than
606/// discarded -- a name is more useful slightly wrong than absent. Use
607/// [`percent_decode`] when the result carries identity rather than being shown.
608pub fn percent_decode_lossy(input: &str) -> String {
609    let bytes = input.as_bytes();
610    let mut out = Vec::with_capacity(bytes.len());
611    let mut index = 0;
612    while index < bytes.len() {
613        if bytes[index] == b'%' {
614            let pair = hex_value(bytes.get(index + 1).copied().unwrap_or(0))
615                .zip(hex_value(bytes.get(index + 2).copied().unwrap_or(0)));
616            if let Some((high, low)) = pair {
617                out.push((high << 4) | low);
618                index += 3;
619                continue;
620            }
621        }
622        out.push(bytes[index]);
623        index += 1;
624    }
625    String::from_utf8_lossy(&out).into_owned()
626}
627
628fn hex_value(byte: u8) -> Option<u8> {
629    match byte {
630        b'0'..=b'9' => Some(byte - b'0'),
631        b'a'..=b'f' => Some(byte - b'a' + 10),
632        b'A'..=b'F' => Some(byte - b'A' + 10),
633        _ => None,
634    }
635}
636
637#[cfg(test)]
638#[path = "tests/content_tests.rs"]
639mod tests;