Files
inkwell/android/ffi/src/models.rs
T
bvandeusenandClaude Opus 5.5 fd1d50662f
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m26s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m16s
android: share a note with a group
The Share sheet lists groups after people, shows a group share as its name and
how many are in it, and shares through the FFI's ShareTarget.

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

906 lines
27 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,
}
}
}
/// A group the admin made, which a note can be shared with (#5177).
#[derive(Debug, Clone, uniffi::Record)]
pub struct ShareGroup {
pub id: String,
pub name: String,
pub member_count: u32,
}
impl From<core_client::ShareGroup> for ShareGroup {
fn from(value: core_client::ShareGroup) -> Self {
let core_client::ShareGroup {
id,
name,
member_count,
} = value;
ShareGroup {
id,
name,
member_count,
}
}
}
/// Everyone a note can be shared with: the other people, and every group.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Directory {
pub members: Vec<Member>,
pub groups: Vec<ShareGroup>,
}
impl From<core_client::Directory> for Directory {
fn from(value: core_client::Directory) -> Self {
let core_client::Directory { members, groups } = value;
Directory {
members: members.into_iter().map(Member::from).collect(),
groups: groups.into_iter().map(ShareGroup::from).collect(),
}
}
}
/// Who a new share goes to: one person, or a group.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum ShareTarget {
Member { id: String },
Group { id: String },
}
impl From<ShareTarget> for core_client::ShareTarget {
fn from(value: ShareTarget) -> Self {
match value {
ShareTarget::Member { id } => core_client::ShareTarget::Member(id),
ShareTarget::Group { id } => core_client::ShareTarget::Group(id),
}
}
}
/// One share of a note, at "view" or "edit": to a person or to a group, never both.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteShare {
pub id: String,
pub member: Option<Member>,
pub group: Option<ShareGroup>,
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,
group,
permission,
created_at: _,
} = value;
NoteShare {
id,
member: member.map(Member::from),
group: group.map(ShareGroup::from),
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());
}
}