Skip to main content

rlobkit_core/
lib.rs

1//! rlobkit-core: PlatformFile, PlatformDirectory, and common file operations.
2
3pub mod error;
4pub mod paths;
5
6pub use error::RlobKitError;
7
8use bytes::Bytes;
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
13use tokio::io::AsyncReadExt;
14
15/// Android-specific bytes I/O function pointer type.
16///
17/// Set at init time by `rlobkit-dialogs` (which has JNI access) to a function
18/// that reads the content at a `content://` URI and returns the bytes, or
19/// writes bytes to a `content://` URI.
20pub type AndroidReadBytes = fn(&str) -> Result<Bytes, RlobKitError>;
21pub type AndroidWriteBytes = fn(&str, &[u8]) -> Result<(), RlobKitError>;
22
23static ANDROID_READ: OnceLock<AndroidReadBytes> = OnceLock::new();
24static ANDROID_WRITE: OnceLock<AndroidWriteBytes> = OnceLock::new();
25
26/// Register Android I/O implementations. Called by `rlobkit-dialogs::init()`
27/// at app startup. No-op on non-Android targets (the pointers are never
28/// invoked when no `uri` field is set).
29pub fn set_android_io(read: AndroidReadBytes, write: AndroidWriteBytes) {
30    let _ = ANDROID_READ.set(read);
31    let _ = ANDROID_WRITE.set(write);
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct PlatformFile {
36    name: String,
37    path: Option<PathBuf>,
38    uri: Option<String>,
39    data: Option<Bytes>,
40    size: Option<u64>,
41    /// Resolved MIME type. Set on Android (from `ContentResolver.getType`) and
42    /// on WASM (from the blob's hint). On desktop, `None` and `extension`/
43    /// `mime_type` derive from the name.
44    mime_type: Option<String>,
45}
46
47impl PlatformFile {
48    pub fn from_path(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
49        Self {
50            name: name.into(),
51            path: Some(path.into()),
52            uri: None,
53            data: None,
54            size: None,
55            mime_type: None,
56        }
57    }
58
59    #[cfg(target_os = "android")]
60    pub fn from_uri(
61        name: impl Into<String>,
62        uri: impl Into<String>,
63        size: Option<u64>,
64        mime_type: Option<String>,
65    ) -> Self {
66        Self {
67            name: name.into(),
68            path: None,
69            uri: Some(uri.into()),
70            data: None,
71            size,
72            mime_type,
73        }
74    }
75
76    #[cfg(target_arch = "wasm32")]
77    pub fn from_blob(name: impl Into<String>, data: Bytes, mime_type: Option<String>) -> Self {
78        let size = Some(data.len() as u64);
79        Self {
80            name: name.into(),
81            path: None,
82            uri: None,
83            data: Some(data),
84            size,
85            mime_type,
86        }
87    }
88
89    /// Display name. Always populated at construction.
90    pub fn name(&self) -> &str {
91        &self.name
92    }
93
94    /// File extension, derived from the display name.
95    pub fn extension(&self) -> Option<&str> {
96        std::path::Path::new(&self.name)
97            .extension()
98            .and_then(|e| e.to_str())
99    }
100
101    /// Returns the resolved MIME when available (Android from
102    /// `ContentResolver.getType`, WASM from the blob's hint), otherwise
103    /// derives from the name's extension.
104    pub fn mime_type(&self) -> Option<String> {
105        if let Some(mime) = &self.mime_type {
106            return Some(mime.clone());
107        }
108        let ext = self.extension()?;
109        Some(
110            mime_guess::from_ext(ext)
111                .first_or_octet_stream()
112                .to_string(),
113        )
114    }
115
116    pub fn path(&self) -> Option<&Path> {
117        self.path.as_deref()
118    }
119
120    /// Content URI on Android. Always returns `None` on platforms where the
121    /// file wasn't sourced from a SAF picker. Haven't gated it for checks
122    pub fn uri(&self) -> Option<&str> {
123        self.uri.as_deref()
124    }
125
126    /// In-memory bytes on WASM. Always returns `None` on platforms where the
127    /// file wasn't sourced from a blob picker. Haven't gated it for checks
128    pub fn data(&self) -> Option<&Bytes> {
129        self.data.as_ref()
130    }
131
132    /// Cached size. `None` if not resolved at construction (desktop, or Android
133    /// pickers that didn't query the size).
134    pub fn size(&self) -> Option<u64> {
135        self.size
136    }
137
138    pub fn read_bytes(&self) -> Result<Bytes, RlobKitError> {
139        if let Some(p) = &self.path {
140            return Ok(Bytes::from(std::fs::read(p)?));
141        }
142        if let Some(u) = &self.uri {
143            let reader = ANDROID_READ.get().ok_or_else(|| {
144                RlobKitError::UnsupportedOperation(
145                    "Android I/O not initialized; call rlobkit_dialogs::init()".into(),
146                )
147            })?;
148            return reader(u);
149        }
150        if let Some(d) = &self.data {
151            return Ok(d.clone());
152        }
153        Err(RlobKitError::UnsupportedOperation(
154            "PlatformFile has no readable source".into(),
155        ))
156    }
157
158    #[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
159    pub async fn read_bytes_async(&self) -> Result<Bytes, RlobKitError> {
160        if let Some(p) = &self.path {
161            let mut file = tokio::fs::File::open(p).await?;
162            let mut buffer = Vec::new();
163            file.read_to_end(&mut buffer).await?;
164            return Ok(Bytes::from(buffer));
165        }
166        if let Some(u) = &self.uri {
167            let reader = ANDROID_READ.get().ok_or_else(|| {
168                RlobKitError::UnsupportedOperation(
169                    "Android I/O not initialized; call rlobkit_dialogs::init()".into(),
170                )
171            })?;
172            return reader(u);
173        }
174        Err(RlobKitError::UnsupportedOperation(
175            "PlatformFile has no readable source".into(),
176        ))
177    }
178
179    #[cfg(target_arch = "wasm32")]
180    pub async fn read_bytes_async(&self) -> Result<Bytes, RlobKitError> {
181        self.read_bytes()
182    }
183
184    pub fn write_bytes(&self, data: &[u8]) -> Result<(), RlobKitError> {
185        if let Some(p) = &self.path {
186            std::fs::write(p, data)?;
187            return Ok(());
188        }
189        if let Some(u) = &self.uri {
190            let writer = ANDROID_WRITE.get().ok_or_else(|| {
191                RlobKitError::UnsupportedOperation(
192                    "Android I/O not initialized; call rlobkit_dialogs::init()".into(),
193                )
194            })?;
195            return writer(u, data);
196        }
197        if self.data.is_some() {
198            return Err(RlobKitError::UnsupportedOperation(
199                "Writing to an in-memory blob is not supported".into(),
200            ));
201        }
202        Err(RlobKitError::UnsupportedOperation(
203            "PlatformFile has no writable destination".into(),
204        ))
205    }
206
207    pub fn write_string(&self, s: &str) -> Result<(), RlobKitError> {
208        self.write_bytes(s.as_bytes())
209    }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct PlatformDirectory {
214    path: PathBuf,
215}
216
217impl PlatformDirectory {
218    pub fn new(path: impl Into<PathBuf>) -> Self {
219        Self { path: path.into() }
220    }
221
222    pub fn path(&self) -> &Path {
223        &self.path
224    }
225
226    pub fn name(&self) -> Option<String> {
227        self.path.file_name()?.to_str().map(String::from)
228    }
229
230    pub fn file(&self, name: &str) -> PlatformFile {
231        PlatformFile::from_path(name, self.path.join(name))
232    }
233
234    #[cfg(not(target_arch = "wasm32"))]
235    pub fn list_files(&self) -> Result<Vec<PlatformFile>, RlobKitError> {
236        let mut files = Vec::new();
237        for entry in std::fs::read_dir(&self.path)? {
238            let entry = entry?;
239            if entry.file_type()?.is_file() {
240                let path = entry.path();
241                let name = path
242                    .file_name()
243                    .and_then(|n| n.to_str())
244                    .unwrap_or("")
245                    .to_string();
246                files.push(PlatformFile::from_path(name, path));
247            }
248        }
249        Ok(files)
250    }
251}
252
253impl std::ops::Div<&str> for &PlatformDirectory {
254    type Output = PlatformFile;
255    fn div(self, rhs: &str) -> PlatformFile {
256        self.file(rhs)
257    }
258}
259
260/// Map a MIME type to a primary file extension
261pub fn mime_to_extension(mime: &str) -> Option<&'static str> {
262    if let Some(extensions) = mime_guess::get_mime_extensions_str(mime) {
263        if let Some(ext) = extensions.first() {
264            return Some(ext);
265        }
266    }
267    match mime {
268        "application/x-clap" => Some("clap"),
269        _ => None,
270    }
271}