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    /// Returns a playback reference without reading or copying the audio.
128    /// Providers retain any platform grant required to reopen this reference.
129    /// Browser references remain valid until the page is closed.
130    fn media_uri(&self) -> Result<String, ContentError> {
131        Err(ContentError::Unsupported(
132            "media references from this provider",
133        ))
134    }
135
136    /// Retains a playback reference without blocking the UI on platform I/O.
137    /// Providers with filesystem access grants override this method and perform
138    /// that work on the blocking executor. No audio is read or copied.
139    fn media_uri_async(&self) -> ContentFuture<'_, Result<String, ContentError>> {
140        Box::pin(async move { self.media_uri() })
141    }
142
143    /// Opens a chunked reader over the bytes.
144    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>>;
145
146    /// Reads every byte. The default drains [`open`](Content::open); providers
147    /// already holding the bytes override it to avoid the copy.
148    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
149        Box::pin(async move {
150            let reader = self.open().await?;
151            drain_reader(&reader).await
152        })
153    }
154}
155
156/// Shared handle to a [`Content`].
157pub type ContentHandle = Rc<dyn Content>;
158
159/// Reads a reader to its end.
160pub async fn drain_reader(reader: &ContentReaderRef) -> Result<Vec<u8>, ContentError> {
161    let mut bytes = Vec::new();
162    while let Some(chunk) = reader.read_chunk().await? {
163        bytes.extend_from_slice(&chunk);
164    }
165    Ok(bytes)
166}
167
168/// A writable destination for streamed content.
169pub trait ContentSink {
170    /// Appends `bytes` to the destination.
171    fn write_chunk(&self, bytes: Vec<u8>) -> ContentFuture<'_, Result<(), ContentError>>;
172
173    /// Commits the destination. Dropping a sink without finishing it discards
174    /// whatever the provider allows it to discard.
175    fn finish(&self) -> ContentFuture<'_, Result<(), ContentError>>;
176}
177
178/// Shared handle to a [`ContentSink`].
179pub type ContentSinkRef = Rc<dyn ContentSink>;
180
181/// Writes every byte of `bytes` into `sink` and commits it.
182pub async fn write_all(sink: &ContentSinkRef, bytes: Vec<u8>) -> Result<(), ContentError> {
183    sink.write_chunk(bytes).await?;
184    sink.finish().await
185}
186
187/// One child of a [`ContentFolder`].
188pub enum ContentEntry {
189    /// A readable file.
190    File(ContentHandle),
191    /// A nested folder.
192    Folder(ContentFolderRef),
193}
194
195impl ContentEntry {
196    /// The child's metadata regardless of kind.
197    pub fn metadata(&self) -> ContentMetadata {
198        match self {
199            Self::File(file) => file.metadata(),
200            Self::Folder(folder) => folder.metadata(),
201        }
202    }
203}
204
205/// A folder granted by a provider.
206pub trait ContentFolder {
207    /// What the provider states about this folder.
208    fn metadata(&self) -> ContentMetadata;
209
210    /// The immediate children.
211    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>>;
212
213    /// A provider-native stream over every file in the tree, when the provider
214    /// discovers files incrementally. Providers that enumerate eagerly return
215    /// `None` and [`folder_files`] walks them instead.
216    fn stream_files(&self) -> Option<ContentStreamRef> {
217        None
218    }
219}
220
221/// Shared handle to a [`ContentFolder`].
222pub type ContentFolderRef = Rc<dyn ContentFolder>;
223
224/// An asynchronous stream of content.
225///
226/// [`next`](ContentStream::next) resolves once the next item is available; the
227/// producer wakes the pending future, so collectors never poll.
228pub trait ContentStream {
229    /// Resolves to the next item, or `None` once the stream is exhausted.
230    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>>;
231
232    /// How many items the provider has produced so far, when it counts them.
233    fn produced(&self) -> Option<usize> {
234        None
235    }
236}
237
238/// Shared handle to a [`ContentStream`].
239pub type ContentStreamRef = Rc<dyn ContentStream>;
240
241/// Streams every file in `folder`'s tree, using the provider's native stream
242/// when it has one and a depth-first walk otherwise.
243pub fn folder_files(folder: ContentFolderRef) -> ContentStreamRef {
244    folder
245        .stream_files()
246        .unwrap_or_else(|| Rc::new(WalkStream::new(folder)))
247}
248
249/// Collects an entire stream. Convenient for tests and small trees; production
250/// collectors consume [`ContentStream::next`] incrementally.
251pub async fn collect_stream(stream: &ContentStreamRef) -> Result<Vec<ContentHandle>, ContentError> {
252    let mut items = Vec::new();
253    while let Some(item) = stream.next().await? {
254        items.push(item);
255    }
256    Ok(items)
257}
258
259struct WalkStream {
260    pending: RefCell<Vec<ContentFolderRef>>,
261    ready: RefCell<VecDeque<ContentHandle>>,
262    produced: std::cell::Cell<usize>,
263}
264
265impl WalkStream {
266    fn new(root: ContentFolderRef) -> Self {
267        Self {
268            pending: RefCell::new(vec![root]),
269            ready: RefCell::new(VecDeque::new()),
270            produced: std::cell::Cell::new(0),
271        }
272    }
273}
274
275impl ContentStream for WalkStream {
276    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
277        Box::pin(async move {
278            loop {
279                if let Some(file) = self.ready.borrow_mut().pop_front() {
280                    self.produced.set(self.produced.get() + 1);
281                    return Ok(Some(file));
282                }
283                let Some(folder) = self.pending.borrow_mut().pop() else {
284                    return Ok(None);
285                };
286                for entry in folder.entries().await? {
287                    match entry {
288                        ContentEntry::File(file) => self.ready.borrow_mut().push_back(file),
289                        ContentEntry::Folder(child) => self.pending.borrow_mut().push(child),
290                    }
291                }
292            }
293        })
294    }
295
296    fn produced(&self) -> Option<usize> {
297        Some(self.produced.get())
298    }
299}
300
301/// The producing half of a [`ContentStream`] fed by a platform callback.
302///
303/// A backend that receives items from outside the composition — an Android
304/// enumeration callback, a drop target, a download — pushes into the channel and
305/// the pending collector future is woken. This is the replacement for every
306/// "drain what is ready each frame" contract.
307pub struct ContentChannel {
308    shared: Rc<ChannelShared>,
309}
310
311struct ChannelShared {
312    ready: RefCell<VecDeque<ContentHandle>>,
313    error: RefCell<Option<ContentError>>,
314    closed: std::cell::Cell<bool>,
315    produced: std::cell::Cell<usize>,
316    waker: RefCell<Option<Waker>>,
317}
318
319impl ChannelShared {
320    fn wake(&self) {
321        if let Some(waker) = self.waker.borrow_mut().take() {
322            waker.wake();
323        }
324    }
325}
326
327impl Default for ContentChannel {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333impl ContentChannel {
334    /// Creates an open channel.
335    pub fn new() -> Self {
336        Self {
337            shared: Rc::new(ChannelShared {
338                ready: RefCell::new(VecDeque::new()),
339                error: RefCell::new(None),
340                closed: std::cell::Cell::new(false),
341                produced: std::cell::Cell::new(0),
342                waker: RefCell::new(None),
343            }),
344        }
345    }
346
347    /// The consuming half, handed to the application.
348    pub fn stream(&self) -> ContentStreamRef {
349        Rc::new(ChannelStream {
350            shared: Rc::clone(&self.shared),
351        })
352    }
353
354    /// Publishes one item and wakes a pending collector.
355    pub fn push(&self, content: ContentHandle) {
356        if self.shared.closed.get() {
357            return;
358        }
359        self.shared.ready.borrow_mut().push_back(content);
360        self.shared.wake();
361    }
362
363    /// Ends the stream with an error and wakes a pending collector.
364    pub fn fail(&self, error: ContentError) {
365        if self.shared.closed.get() {
366            return;
367        }
368        *self.shared.error.borrow_mut() = Some(error);
369        self.shared.closed.set(true);
370        self.shared.wake();
371    }
372
373    /// Ends the stream normally and wakes a pending collector.
374    pub fn close(&self) {
375        if self.shared.closed.get() {
376            return;
377        }
378        self.shared.closed.set(true);
379        self.shared.wake();
380    }
381
382    /// Whether the stream has been ended.
383    pub fn is_closed(&self) -> bool {
384        self.shared.closed.get()
385    }
386}
387
388struct ChannelStream {
389    shared: Rc<ChannelShared>,
390}
391
392impl ContentStream for ChannelStream {
393    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
394        Box::pin(ChannelNext {
395            shared: Rc::clone(&self.shared),
396        })
397    }
398
399    fn produced(&self) -> Option<usize> {
400        Some(self.shared.produced.get())
401    }
402}
403
404struct ChannelNext {
405    shared: Rc<ChannelShared>,
406}
407
408impl Future for ChannelNext {
409    type Output = Result<Option<ContentHandle>, ContentError>;
410
411    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
412        if let Some(item) = self.shared.ready.borrow_mut().pop_front() {
413            self.shared.produced.set(self.shared.produced.get() + 1);
414            return Poll::Ready(Ok(Some(item)));
415        }
416        if let Some(error) = self.shared.error.borrow_mut().take() {
417            return Poll::Ready(Err(error));
418        }
419        if self.shared.closed.get() {
420            return Poll::Ready(Ok(None));
421        }
422        *self.shared.waker.borrow_mut() = Some(context.waker().clone());
423        Poll::Pending
424    }
425}
426
427/// Content already held in memory: clipboard drops, web `File` payloads, tests.
428pub struct BytesContent {
429    metadata: ContentMetadata,
430    bytes: Rc<Vec<u8>>,
431}
432
433impl BytesContent {
434    /// Wraps `bytes` under `metadata`, filling in the byte length.
435    pub fn new(metadata: ContentMetadata, bytes: Vec<u8>) -> Self {
436        let len = bytes.len() as u64;
437        Self {
438            metadata: ContentMetadata {
439                len: Some(len),
440                ..metadata
441            },
442            bytes: Rc::new(bytes),
443        }
444    }
445
446    /// Wraps `bytes` under a display name.
447    pub fn named(name: impl Into<String>, bytes: Vec<u8>) -> Self {
448        Self::new(ContentMetadata::named(name), bytes)
449    }
450
451    /// A shared handle to this content.
452    pub fn handle(self) -> ContentHandle {
453        Rc::new(self)
454    }
455}
456
457impl Content for BytesContent {
458    fn metadata(&self) -> ContentMetadata {
459        self.metadata.clone()
460    }
461
462    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>> {
463        let bytes = Rc::clone(&self.bytes);
464        Box::pin(async move {
465            Ok(Rc::new(BytesReader {
466                bytes,
467                offset: std::cell::Cell::new(0),
468            }) as ContentReaderRef)
469        })
470    }
471
472    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
473        let bytes = Rc::clone(&self.bytes);
474        Box::pin(async move { Ok(bytes.as_ref().clone()) })
475    }
476}
477
478struct BytesReader {
479    bytes: Rc<Vec<u8>>,
480    offset: std::cell::Cell<usize>,
481}
482
483impl ContentReader for BytesReader {
484    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>> {
485        Box::pin(async move {
486            let start = self.offset.get();
487            if start >= self.bytes.len() {
488                return Ok(None);
489            }
490            let end = (start + DEFAULT_CHUNK_LEN).min(self.bytes.len());
491            self.offset.set(end);
492            Ok(Some(self.bytes[start..end].to_vec()))
493        })
494    }
495}
496
497/// A folder whose children are already known.
498pub struct ReadyFolder {
499    metadata: ContentMetadata,
500    entries: RefCell<Option<Vec<ContentEntry>>>,
501}
502
503impl ReadyFolder {
504    /// Wraps already-enumerated `entries` under `metadata`.
505    pub fn new(metadata: ContentMetadata, entries: Vec<ContentEntry>) -> Self {
506        Self {
507            metadata,
508            entries: RefCell::new(Some(entries)),
509        }
510    }
511
512    /// A shared handle to this folder.
513    pub fn handle(self) -> ContentFolderRef {
514        Rc::new(self)
515    }
516}
517
518impl ContentFolder for ReadyFolder {
519    fn metadata(&self) -> ContentMetadata {
520        self.metadata.clone()
521    }
522
523    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>> {
524        Box::pin(async move {
525            self.entries
526                .borrow_mut()
527                .take()
528                .ok_or_else(|| ContentError::Io("folder entries already consumed".into()))
529        })
530    }
531}
532
533/// Turns a provider URI into readable content.
534///
535/// Every source that names content by URI rather than handing over bytes —
536/// a document intent, a shared item, a dropped file, a media source — resolves
537/// through this, so applications never learn what a `content://`, `file://` or
538/// blob URI means on the current platform.
539pub trait ContentResolver {
540    /// Resolves `uri`, or `None` when this platform cannot open it.
541    fn resolve(&self, uri: &str) -> Option<ContentHandle>;
542}
543
544/// Shared handle to a [`ContentResolver`].
545pub type ContentResolverRef = Rc<dyn ContentResolver>;
546
547/// Installs the platform content resolver (Android's `ContentResolver`, the
548/// iOS document store, the browser's blob registry).
549pub fn set_platform_content_resolver(resolver: ContentResolverRef) {
550    crate::thread_state::with(|state| *state.content_resolver.borrow_mut() = Some(resolver));
551}
552
553/// Removes any installed platform content resolver.
554pub fn clear_platform_content_resolver() {
555    crate::thread_state::with(|state| *state.content_resolver.borrow_mut() = None);
556}
557
558/// Resolves `uri` through the platform resolver, falling back to the local
559/// filesystem for `file://` URIs and plain paths.
560pub fn resolve_content(uri: &str) -> Option<ContentHandle> {
561    if let Some(resolver) =
562        crate::thread_state::with(|state| state.content_resolver.borrow().clone())
563        && let Some(content) = resolver.resolve(uri)
564    {
565        return Some(content);
566    }
567    #[cfg(not(target_arch = "wasm32"))]
568    {
569        if let Some(path) = crate::media::path_from_uri(uri) {
570            return Some(file_content(path));
571        }
572    }
573    let _ = uri;
574    None
575}
576
577#[cfg(not(target_arch = "wasm32"))]
578pub use file::{FileContent, FileFolder, FileSink, file_content, file_folder};
579#[cfg(not(target_arch = "wasm32"))]
580pub use threaded::{
581    BlockingSink, copy_content, open_threaded, read_full, run_threaded, threaded_sink,
582};
583
584#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
585pub(crate) mod blob;
586#[cfg(not(target_arch = "wasm32"))]
587mod file;
588#[cfg(not(target_arch = "wasm32"))]
589mod threaded;
590
591/// Decodes the percent-escapes in a platform content URI, refusing input it
592/// cannot decode exactly.
593///
594/// Android hands a folder or document back as an opaque `content://` URI whose
595/// readable name is percent-encoded inside it, so anything that wants to show
596/// the user which folder they picked has to decode it. The pair here mirrors
597/// [`String::from_utf8`] and [`String::from_utf8_lossy`]: this one answers
598/// `None` rather than inventing a character, and [`percent_decode_lossy`] is
599/// the best-effort form for text that is only going to be displayed.
600///
601/// Prefer this when the result is used as an identity -- a key, a filename, a
602/// fingerprint -- where a replacement character would silently make two
603/// different inputs look the same.
604pub fn percent_decode(input: &str) -> Option<String> {
605    let bytes = input.as_bytes();
606    let mut out = Vec::with_capacity(bytes.len());
607    let mut index = 0;
608    while index < bytes.len() {
609        if bytes[index] == b'%' {
610            let high = bytes.get(index + 1).copied().and_then(hex_value)?;
611            let low = bytes.get(index + 2).copied().and_then(hex_value)?;
612            out.push((high << 4) | low);
613            index += 3;
614        } else {
615            out.push(bytes[index]);
616            index += 1;
617        }
618    }
619    String::from_utf8(out).ok()
620}
621
622/// Decodes the percent-escapes in a platform content URI for display.
623///
624/// A byte sequence that is not valid UTF-8 becomes U+FFFD, and an escape that
625/// is truncated or not hexadecimal is passed through as written rather than
626/// discarded -- a name is more useful slightly wrong than absent. Use
627/// [`percent_decode`] when the result carries identity rather than being shown.
628pub fn percent_decode_lossy(input: &str) -> String {
629    let bytes = input.as_bytes();
630    let mut out = Vec::with_capacity(bytes.len());
631    let mut index = 0;
632    while index < bytes.len() {
633        if bytes[index] == b'%' {
634            let pair = hex_value(bytes.get(index + 1).copied().unwrap_or(0))
635                .zip(hex_value(bytes.get(index + 2).copied().unwrap_or(0)));
636            if let Some((high, low)) = pair {
637                out.push((high << 4) | low);
638                index += 3;
639                continue;
640            }
641        }
642        out.push(bytes[index]);
643        index += 1;
644    }
645    String::from_utf8_lossy(&out).into_owned()
646}
647
648fn hex_value(byte: u8) -> Option<u8> {
649    match byte {
650        b'0'..=b'9' => Some(byte - b'0'),
651        b'a'..=b'f' => Some(byte - b'a' + 10),
652        b'A'..=b'F' => Some(byte - b'A' + 10),
653        _ => None,
654    }
655}
656
657#[cfg(test)]
658#[path = "tests/content_tests.rs"]
659mod tests;