android: shows and attaches files, and the share sheet takes images
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 1s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 11m6s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 1s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 11m6s
The phone downloaded every attachment and drew none of them, so a photo note looked empty. Now: - Cards show a note's first image and name its other files; the editor shows every image at full width and every file as a row. Tapping one opens it in whatever app handles its type (a cache copy under its real name, through a FileProvider that serves only those copies). Each can be removed, and a file the server refused says why under it. - The editor's toolbar has an Attach button (any type, several at once). Files are stored on the phone straight away and upload on the next sync that reaches a server, through the core's step-6 path. Link previews show in the editor too, and can be dismissed. - Share → Inkwell accepts one or several images, with or without a caption, finishing #1899's deferred image/* target. - The FFI gains add_attachment, delete_attachment, delete_preview and blob_path. The sync summary counts uploads and failed uploads. Images decode at the size they are drawn (BitmapFactory sampling plus EXIF rotation, small LRU cache), so no image library is added. Files over 50 MB are refused on the phone before they are read whole into memory. Task #5169. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -392,6 +392,56 @@ impl Inkwell {
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ────────────────────────── attachments and previews ──────────────────────────
|
||||
|
||||
/// Attach a file. The bytes go into the blob store now and up to the server on
|
||||
/// the next sync that can reach one, so attaching works with no network.
|
||||
///
|
||||
/// The bytes cross the FFI as one buffer. Kotlin caps what it reads before
|
||||
/// calling, because this copies the whole file into Rust's memory once.
|
||||
pub fn add_attachment(
|
||||
&self,
|
||||
note_id: String,
|
||||
filename: String,
|
||||
mime: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::add_attachment(&conn, &self.blobs, ¬e_id, &filename, &mime, &bytes)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn delete_attachment(
|
||||
&self,
|
||||
note_id: String,
|
||||
attachment_id: String,
|
||||
) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::delete_attachment(&conn, ¬e_id, &attachment_id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn delete_preview(&self, note_id: String, preview_id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::delete_preview(&conn, ¬e_id, &preview_id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Where this device holds an attachment's bytes, or None when it doesn't yet
|
||||
/// (still downloading, or the download failed).
|
||||
///
|
||||
/// A path rather than the bytes, so Kotlin can decode an image at the size it
|
||||
/// will be drawn instead of pulling the full file across the FFI for a thumbnail.
|
||||
pub fn blob_path(&self, sha256: String) -> Option<String> {
|
||||
self.blobs
|
||||
.path(&sha256)
|
||||
.filter(|p| p.is_file())
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
// ─────────────────────────────── sync ────────────────────────────────
|
||||
|
||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
||||
@@ -882,6 +932,56 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A file attached here lands in the blob store, is findable by its hash, and
|
||||
/// leaves both the note and the board's view of it when removed.
|
||||
#[test]
|
||||
fn an_attached_file_is_stored_found_and_removable() {
|
||||
let dir = scratch_dir();
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Receipt")).expect("create");
|
||||
|
||||
let attached = app
|
||||
.add_attachment(
|
||||
note.id.clone(),
|
||||
"receipt.png".into(),
|
||||
"image/png".into(),
|
||||
b"not really a png".to_vec(),
|
||||
)
|
||||
.expect("attach");
|
||||
assert_eq!(attached.attachments.len(), 1);
|
||||
let att = &attached.attachments[0];
|
||||
assert_eq!(att.filename.as_deref(), Some("receipt.png"));
|
||||
assert_eq!(att.mime, "image/png");
|
||||
assert_eq!(att.upload_error, None);
|
||||
|
||||
let sha = att.sha256.clone().expect("a stored file carries its hash");
|
||||
let path = app.blob_path(sha.clone()).expect("the bytes are on disk");
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"not really a png");
|
||||
assert_eq!(
|
||||
app.blob_path("0".repeat(64)),
|
||||
None,
|
||||
"an unknown hash has no path"
|
||||
);
|
||||
|
||||
let after = app
|
||||
.delete_attachment(note.id.clone(), att.id.clone())
|
||||
.expect("remove");
|
||||
assert!(after.attachments.is_empty());
|
||||
|
||||
assert!(
|
||||
app.add_attachment(
|
||||
"missing".into(),
|
||||
"a.txt".into(),
|
||||
"text/plain".into(),
|
||||
vec![1]
|
||||
)
|
||||
.is_err(),
|
||||
"attaching to a note that doesn't exist is refused"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Snooze writes a future instant from the CORE's clock; complete clears it.
|
||||
#[test]
|
||||
fn reminders_can_be_snoozed_and_completed() {
|
||||
|
||||
Reference in New Issue
Block a user