Files
inkwell/android/ffi/src/models.rs
T
bvandeusenandClaude Opus 5.5 aa36b43dc3
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s
core: shared notes on the desktop and phone, and Share from the desktop
The core pulls with shares from a server offering them (protocol 6): a note
says how it is held (owner, edit, view) and who shared it, and a revoked note
leaves the device. The first such pull starts the feed over once, so notes
shared before this build arrive. The store refuses what a share doesn't allow
(view: everything; edit: anything but the text), push sends only the text of
someone else's note, and their notes stay out of trash, reminders and
reordering. Unlinking drops them.

The Share dialog's calls go to the linked server over the device token, as
Tauri commands and through the FFI. The desktop now offers Share and "Shared
with me"; unlinked, the dialog says sharing needs a server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:33:16 -04:00

847 lines
26 KiB
Rust

//! The types that cross into Kotlin.
//!
//! These MIRROR `inkwell_core::local::models` rather than reusing it. The core's
//! shapes are serde structs whose field names and optionality are contracted with the
//! shared Vue frontend; hanging uniffi derives on them would couple two very
//! different consumers to one definition and put a `serde_json::Value` (which has no
//! uniffi representation) in the middle of it.
//!
//! The cost of mirroring is drift — an Android client quietly missing a field the
//! desktop gained. Every conversion below therefore DESTRUCTURES the core struct
//! exhaustively instead of reading fields it cares about. Add a field to
//! `core::local::models::Note` and this file stops compiling until Android is told
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
//! style here; please keep it.
use inkwell_core::local::models as core_models;
use inkwell_core::sync::client as core_client;
use inkwell_core::sync::compat as core_compat;
use inkwell_core::sync::engine as core_engine;
use inkwell_core::sync::pull as core_pull;
use inkwell_core::sync::push as core_push;
use inkwell_core::sync::state as core_state;
/// A note, with everything needed to render a card or open the editor.
///
/// Timestamps are RFC3339 strings, not a date type: that is what SQLite holds and
/// what the server speaks, and converting here would mean this layer picking a
/// calendar/timezone policy that belongs to the UI.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Note {
pub id: String,
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
pub trashed: bool,
pub deleted_at: Option<String>,
pub remind_at: Option<String>,
pub recurrence: Option<String>,
pub labels: Vec<NoteLabel>,
pub items: Vec<ChecklistItem>,
pub attachments: Vec<Attachment>,
pub previews: Vec<LinkPreview>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
/// How this account holds the note (#5175): "owner", or "edit"/"view" for one
/// someone shared with it.
pub permission: String,
/// Whether the owner has shared it with anyone.
pub shared: bool,
/// Who shared it with us; absent on our own notes.
pub shared_by: Option<SharedBy>,
}
/// The owner of a note shared with this account.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SharedBy {
pub id: String,
pub display_name: String,
}
/// A checklist item as it sits in a note's body.
///
/// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks
/// a body line by line needs the text, the state and the position together — the card
/// to draw a box in the right place, the block editor to know where one block ends.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyItem {
pub line: u32,
pub text: String,
pub checked: bool,
}
impl From<inkwell_core::local::derive::DerivedItem> for BodyItem {
fn from(i: inkwell_core::local::derive::DerivedItem) -> Self {
let inkwell_core::local::derive::DerivedItem {
text,
checked,
line,
} = i;
BodyItem {
line,
text,
checked,
}
}
}
/// One `#tag` and where it sits in a note's body.
///
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyTag {
pub line: u32,
pub start: u32,
pub end: u32,
pub name: String,
}
impl From<inkwell_core::local::derive::DerivedTag> for BodyTag {
fn from(t: inkwell_core::local::derive::DerivedTag) -> Self {
let inkwell_core::local::derive::DerivedTag {
line,
start,
end,
name,
} = t;
BodyTag {
line,
start,
end,
name,
}
}
}
/// An Android build the linked server is offering, already judged to be newer.
///
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
/// reason every other record here is one: the core's shapes are contracted with
/// other consumers, and `url` in particular is an implementation detail of how
/// the download is fetched — the app never needs it, because it asks the core to
/// do the downloading.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ClientUpdate {
/// For people to read.
pub version: String,
/// For machines to compare.
pub version_code: i64,
pub size: i64,
}
impl From<inkwell_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: inkwell_core::sync::client::ClientRelease) -> Self {
// Destructured exhaustively, like every other conversion in this file: a
// field added upstream stops this compiling until Android is told what to
// do with it, which turns silent drift into a build error.
let inkwell_core::sync::client::ClientRelease {
version,
version_code,
size,
sha256: _,
url: _,
} = r;
ClientUpdate {
version,
version_code,
size,
}
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteLabel {
pub id: String,
pub name: String,
pub color: String,
/// True when attached because of a `#tag` in the body, so the UI can show it is
/// owned by the text and not independently removable.
pub via_tag: bool,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ChecklistItem {
pub id: String,
pub text: String,
pub checked: bool,
pub position: i64,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct Attachment {
pub id: String,
pub url: String,
pub filename: Option<String>,
pub mime: String,
pub size: Option<i64>,
pub sha256: Option<String>,
/// Why the server refused a file attached on this device. None otherwise.
pub upload_error: Option<String>,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct LinkPreview {
pub id: String,
pub url: String,
pub title: Option<String>,
pub description: Option<String>,
pub image_url: Option<String>,
pub site_name: Option<String>,
}
impl From<core_models::Note> for Note {
fn from(value: core_models::Note) -> Self {
// Exhaustive on purpose — see the module header.
let core_models::Note {
id,
display_title,
body,
position,
pinned,
archived,
trashed,
deleted_at,
remind_at,
recurrence,
labels,
items,
attachments,
previews,
created_at,
updated_at,
permission,
shared,
shared_by,
} = value;
Note {
id,
display_title,
body,
position,
pinned,
archived,
trashed,
deleted_at,
remind_at,
recurrence,
labels: labels.into_iter().map(NoteLabel::from).collect(),
items: items.into_iter().map(ChecklistItem::from).collect(),
attachments: attachments.into_iter().map(Attachment::from).collect(),
previews: previews.into_iter().map(LinkPreview::from).collect(),
created_at,
updated_at,
permission,
shared,
shared_by: shared_by.map(|by| SharedBy {
id: by.id,
display_name: by.display_name,
}),
}
}
}
impl From<core_models::NoteLabel> for NoteLabel {
fn from(value: core_models::NoteLabel) -> Self {
let core_models::NoteLabel {
id,
name,
color,
via_tag,
} = value;
NoteLabel {
id,
name,
color,
via_tag,
}
}
}
impl From<core_models::ChecklistItem> for ChecklistItem {
fn from(value: core_models::ChecklistItem) -> Self {
let core_models::ChecklistItem {
id,
text,
checked,
position,
} = value;
ChecklistItem {
id,
text,
checked,
position,
}
}
}
impl From<core_models::Attachment> for Attachment {
fn from(value: core_models::Attachment) -> Self {
let core_models::Attachment {
id,
url,
filename,
mime,
size,
sha256,
upload_error,
} = value;
Attachment {
id,
url,
filename,
mime,
size,
sha256,
upload_error,
}
}
}
impl From<core_models::LinkPreview> for LinkPreview {
fn from(value: core_models::LinkPreview) -> Self {
let core_models::LinkPreview {
id,
url,
title,
description,
image_url,
site_name,
} = value;
LinkPreview {
id,
url,
title,
description,
image_url,
site_name,
}
}
}
/// A label, as the sidebar lists them.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Label {
pub id: String,
pub name: String,
/// Same colour vocabulary as notes, so one palette serves both.
pub color: String,
/// How many notes carry it. Only populated in listings — `None` elsewhere,
/// matching the REST single-label responses.
pub count: Option<i64>,
}
impl From<core_models::Label> for Label {
fn from(value: core_models::Label) -> Self {
let core_models::Label {
id,
name,
color,
count,
} = value;
Label {
id,
name,
color,
count,
}
}
}
// ───────────────────────────── queries and edits ─────────────────────────────
/// What the board is asking for. Mirrors the core's `ListQuery`.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteQuery {
/// "notes" | "archive" | "trash" | "reminders" | "labels" — the core validates.
pub view: String,
pub label_id: Option<String>,
pub sort: Option<String>,
pub facets: Option<NoteFacets>,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteFacets {
pub q: Option<String>,
pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>,
pub created_after: Option<String>,
pub created_before: Option<String>,
/// "with_me": only notes someone else shared with this account.
pub shared: Option<String>,
}
impl From<NoteQuery> for core_models::ListQuery {
fn from(value: NoteQuery) -> Self {
let NoteQuery {
view,
label_id,
sort,
facets,
} = value;
core_models::ListQuery {
view,
label_id,
sort,
facets: facets.map(core_models::Facets::from),
}
}
}
impl From<NoteFacets> for core_models::Facets {
fn from(value: NoteFacets) -> Self {
let NoteFacets {
q,
label,
has_reminder,
has_attachment,
created_after,
created_before,
shared,
} = value;
core_models::Facets {
q,
label,
has_reminder,
has_attachment,
created_after,
created_before,
shared,
}
}
}
/// A new note.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub body: String,
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
/// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>,
}
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft { body, items } = value;
core_models::NoteCreateInput { body, items }
}
}
/// One field-level change to a note.
///
/// A LIST of these rather than a struct of optional fields, because the core's patch
/// semantics distinguish three states — leave alone, set to a value, and clear to
/// null — and Kotlin has no way to express the third with a nullable field.
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Body { value: String },
Pinned { value: bool },
Archived { value: bool },
RemindAt { value: String },
ClearRemindAt,
Recurrence { value: String },
ClearRecurrence,
}
impl NoteEdit {
/// The (key, value) pair this edit contributes to the core's JSON patch.
///
/// The core reads a patch object where a present key means "change this" and a
/// null value means "clear it" — the shape the REST API and the Tauri commands
/// both already speak. Translating here keeps that one patch format in one
/// place instead of teaching a second dialect to the store.
fn entry(self) -> (&'static str, serde_json::Value) {
use serde_json::Value;
match self {
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
NoteEdit::ClearRemindAt => ("remind_at", Value::Null),
NoteEdit::Recurrence { value } => ("recurrence", Value::String(value)),
NoteEdit::ClearRecurrence => ("recurrence", Value::Null),
}
}
}
/// Fold a list of edits into the single patch object the store applies.
///
/// Later edits win on a repeated key, which is what a caller batching "set a
/// reminder, then clear it" would expect.
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
let mut map = serde_json::Map::new();
for edit in edits {
let (key, value) = edit.entry();
map.insert(key.to_string(), value);
}
serde_json::Value::Object(map)
}
// ───────────────────────────────── sync ─────────────────────────────────
/// What the UI may know about the link. Carries no device token, deliberately —
/// the core withholds it from `Status` for the same reason, and a bearer token has
/// no business in UI state.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SyncStatus {
pub linked: bool,
pub server_url: Option<String>,
pub last_cursor: i64,
pub last_sync_at: Option<String>,
}
impl From<core_state::Status> for SyncStatus {
fn from(value: core_state::Status) -> Self {
let core_state::Status {
linked,
server_url,
last_cursor,
last_sync_at,
} = value;
SyncStatus {
linked,
server_url,
last_cursor,
last_sync_at,
}
}
}
/// What a server said about itself, before committing to anything.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ProbeResult {
/// Normalised by the core — this, not what the user typed, is what gets stored.
pub base_url: String,
pub site_name: Option<String>,
pub version: Option<String>,
pub trash_retention_days: Option<u32>,
pub compatibility: Compatibility,
}
/// Whether this client and that server can sync at all.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum Compatibility {
Ok,
/// Safe to sync, but these named capabilities are missing. The UI should say so
/// rather than let a feature silently do nothing.
Degraded {
unavailable: Vec<String>,
},
/// Do not sync. `client_must_update` says which side can fix it, so the message
/// can be actionable.
Incompatible {
reason: String,
client_must_update: bool,
},
}
impl From<core_compat::Compatibility> for Compatibility {
fn from(value: core_compat::Compatibility) -> Self {
match value {
core_compat::Compatibility::Ok => Compatibility::Ok,
core_compat::Compatibility::Degraded { unavailable } => {
Compatibility::Degraded { unavailable }
}
core_compat::Compatibility::Incompatible {
reason,
client_must_update,
} => Compatibility::Incompatible {
reason,
client_must_update,
},
}
}
}
impl From<core_client::ProbeResult> for ProbeResult {
fn from(value: core_client::ProbeResult) -> Self {
let core_client::ProbeResult {
base_url,
server,
compatibility,
} = value;
let core_compat::ServerInfo {
site_name,
version,
// Protocol numbers are the raw material of the compatibility verdict,
// which is already carried above in a form the UI can act on. Sending
// them too would invite a second, worse judgement being made in Kotlin.
sync_protocol_version: _,
min_client_protocol_version: _,
sync_features: _,
trash_retention_days,
} = server;
ProbeResult {
base_url,
site_name,
version,
trash_retention_days,
compatibility: compatibility.into(),
}
}
}
/// Who the server thinks this device belongs to.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Identity {
pub id: String,
pub email: String,
pub display_name: String,
}
impl From<core_client::Identity> for Identity {
fn from(value: core_client::Identity) -> Self {
let core_client::Identity {
id,
email,
display_name,
} = value;
Identity {
id,
email,
display_name,
}
}
}
/// Someone on the instance a note can be shared with (#5175).
#[derive(Debug, Clone, uniffi::Record)]
pub struct Member {
pub id: String,
pub display_name: String,
pub email: String,
}
impl From<core_client::Member> for Member {
fn from(value: core_client::Member) -> Self {
let core_client::Member {
id,
display_name,
email,
} = value;
Member {
id,
display_name,
email,
}
}
}
/// One person a note is shared with, at "view" or "edit".
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteShare {
pub id: String,
pub member: Member,
pub permission: String,
}
impl From<core_client::NoteShare> for NoteShare {
fn from(value: core_client::NoteShare) -> Self {
// `created_at` stays behind: nothing on the phone orders or shows by it.
let core_client::NoteShare {
id,
member,
permission,
created_at: _,
} = value;
NoteShare {
id,
member: member.into(),
permission,
}
}
}
/// What became of this device's token on the server during an unlink.
///
/// Separate from the local result because the local half always succeeds and the
/// remote half may not — someone unlinking a machine they are selling deserves to be
/// told plainly that the token is still live.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum RevokeOutcome {
Revoked,
/// This server predates the self-revoke route. Only the web app can retire it.
Unsupported,
Failed {
reason: String,
},
/// Nothing to revoke; the app wasn't linked.
Skipped,
}
impl From<core_client::RevokeOutcome> for RevokeOutcome {
fn from(value: core_client::RevokeOutcome) -> Self {
match value {
core_client::RevokeOutcome::Revoked => RevokeOutcome::Revoked,
core_client::RevokeOutcome::Unsupported => RevokeOutcome::Unsupported,
core_client::RevokeOutcome::Failed { reason } => RevokeOutcome::Failed { reason },
core_client::RevokeOutcome::Skipped => RevokeOutcome::Skipped,
}
}
}
/// The result of one full push-then-pull cycle.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SyncOutcome {
pub push: PushSummary,
pub pull: PullSummary,
/// The state after the cycle, so the UI refreshes from one call rather than
/// following every sync with a status query.
pub status: SyncStatus,
}
/// Counts are `u64` because the core uses `usize`, which has no uniffi
/// representation. Widening is lossless on every target we build for; narrowing to
/// u32 would be a silent truncation waiting for a very large sync.
#[derive(Debug, Clone, uniffi::Record)]
pub struct PushSummary {
pub batches: u64,
pub sent: u64,
pub created: u64,
pub applied: u64,
/// The server had a newer edit and kept it. Not a failure — the local row stops
/// being dirty and the following pull adopts the server's version.
pub kept: u64,
pub noop: u64,
/// Still dirty, and surfaced: these need a human (a duplicate label name is the
/// realistic case). Silently retrying forever would be the wrong shape.
pub rejected: u64,
pub errors: Vec<String>,
/// Files attached on this device that reached the server this cycle.
pub uploaded: u64,
/// Files that didn't; their reasons are in `errors`.
pub upload_failed: u64,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct PullSummary {
pub pages: u64,
pub notes_applied: u64,
pub notes_deleted: u64,
pub labels_applied: u64,
pub labels_deleted: u64,
pub cursor: i64,
/// Rows that still held unpushed local edits when the server's version landed on
/// top. Should be 0 in a normal cycle, because push runs first; anything higher
/// means local work was overwritten, which is worth saying out loud.
pub clobbered_dirty: u64,
pub blobs_downloaded: u64,
/// Attachments whose bytes couldn't be fetched or failed verification. Counted
/// rather than fatal.
pub blobs_failed: u64,
}
impl From<core_push::PushSummary> for PushSummary {
fn from(value: core_push::PushSummary) -> Self {
let core_push::PushSummary {
batches,
sent,
created,
applied,
kept,
noop,
rejected,
errors,
uploaded,
upload_failed,
} = value;
PushSummary {
batches: batches as u64,
sent: sent as u64,
created: created as u64,
applied: applied as u64,
kept: kept as u64,
noop: noop as u64,
rejected: rejected as u64,
errors,
uploaded: uploaded as u64,
upload_failed: upload_failed as u64,
}
}
}
impl From<core_pull::PullSummary> for PullSummary {
fn from(value: core_pull::PullSummary) -> Self {
let core_pull::PullSummary {
pages,
notes_applied,
notes_deleted,
labels_applied,
labels_deleted,
cursor,
clobbered_dirty,
blobs_downloaded,
blobs_failed,
} = value;
PullSummary {
pages: pages as u64,
notes_applied: notes_applied as u64,
notes_deleted: notes_deleted as u64,
labels_applied: labels_applied as u64,
labels_deleted: labels_deleted as u64,
cursor,
clobbered_dirty: clobbered_dirty as u64,
blobs_downloaded: blobs_downloaded as u64,
blobs_failed: blobs_failed as u64,
}
}
}
impl From<core_engine::SyncOutcome> for SyncOutcome {
fn from(value: core_engine::SyncOutcome) -> Self {
let core_engine::SyncOutcome { push, pull, status } = value;
SyncOutcome {
push: push.into(),
pull: pull.into(),
status: status.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_set_and_a_clear_are_different_patch_entries() {
let set = patch_from(vec![NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
}]);
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
assert!(
cleared["remind_at"].is_null(),
"a clear must reach the store as JSON null — an absent key means \
'leave alone', which is a different instruction"
);
}
#[test]
fn an_empty_edit_list_is_an_empty_patch() {
// Not merely tidy: the core rejects a non-object patch, and a UI that
// batches edits may well end up sending none.
assert_eq!(patch_from(vec![]), serde_json::json!({}));
}
#[test]
fn later_edits_win_on_a_repeated_field() {
let patch = patch_from(vec![
NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
},
NoteEdit::ClearRemindAt,
]);
assert!(patch["remind_at"].is_null());
}
}