diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index c557864..d43b869 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -24,6 +24,11 @@ on: # crate since the Android client binds the same code, so a change there is a # change to this app even though nothing under desktop/ moved. - "core/**" + # The Android uniffi shim. It builds no desktop artifact, but it is a + # workspace member, so this lane's `cargo clippy --all-targets` is what + # compiles and lints it — and until the Android lane exists (M12 step 5), + # it is the ONLY thing that does. + - "android/**" # The workspace manifest and lockfile, which now live at the repo root. - "Cargo.toml" - "Cargo.lock" diff --git a/Cargo.lock b/Cargo.lock index 60fadd5..e0d09b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -85,6 +91,72 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.119", +] + +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow 1.0.4", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + [[package]] name = "atk" version = "0.18.2" @@ -132,6 +204,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -280,6 +361,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "cargo_metadata" version = "0.19.2" @@ -287,7 +378,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", - "cargo-platform", + "cargo-platform 0.1.9", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform 0.3.3", "semver", "serde", "serde_json", @@ -359,6 +464,45 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "combine" version = "4.6.7" @@ -953,6 +1097,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -1263,6 +1416,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1996,6 +2160,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -2091,6 +2261,16 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2543,6 +2723,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.10.0" @@ -3076,6 +3262,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3356,6 +3562,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "socket2" version = "0.6.5" @@ -3420,6 +3632,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -3842,7 +4060,7 @@ checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", - "cargo_metadata", + "cargo_metadata 0.19.2", "ctor", "dom_query", "dunce", @@ -3905,6 +4123,15 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3974,6 +4201,18 @@ dependencies = [ "thoughtsync-core", ] +[[package]] +name = "thoughtsync-ffi" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "thiserror 2.0.20", + "thoughtsync-core", + "tokio", + "uniffi", +] + [[package]] name = "time" version = "0.3.55" @@ -4355,6 +4594,128 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "uniffi" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata 0.23.1", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata 0.23.1", + "fs-err", + "glob", + "goblin", + "heck 0.5.0", + "indexmap 2.14.0", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 1.1.4+spec-1.1.0", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uniffi_macros" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml 1.1.4+spec-1.1.0", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4655,6 +5016,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 628329b..1118a78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ -# Rust workspace. Two members today: the framework-free client core, and the Tauri -# desktop app that wraps it. The Android client becomes a third consumer of `core` -# through uniffi (Scribe note 2730) — which is the reason the core is a crate at all -# rather than a module inside the desktop app. +# Rust workspace. The framework-free client core, and the two shims that wrap it: +# the Tauri desktop app and the uniffi bindings the native Android client loads. +# Neither shim owns the core — that is the reason it is a crate at all rather than a +# module inside the desktop app (Scribe note 2730). [workspace] resolver = "2" -members = ["core", "desktop/src-tauri"] +members = ["core", "desktop/src-tauri", "android/ffi"] # Shared pins, so two consumers of the core cannot drift onto different versions of # the same dependency and resolve differently. diff --git a/android/ffi/Cargo.toml b/android/ffi/Cargo.toml new file mode 100644 index 0000000..9b315f3 --- /dev/null +++ b/android/ffi/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "thoughtsync-ffi" +version = "0.1.0" +description = "uniffi bindings exposing thoughtsync-core to the native Android client" +authors = ["bvandeusen"] +edition = "2021" + +[lib] +# cdylib is the `.so` Android's System.loadLibrary opens. `lib` alongside it so the +# bindgen binary below — and this crate's own tests — can use the crate normally; +# a cdylib-only crate is unusable from Rust. +crate-type = ["cdylib", "lib"] +name = "thoughtsync_ffi" + +[dependencies] +thoughtsync-core = { path = "../../core" } +serde_json = { workspace = true } +log = { workspace = true } + +# tokio lets an exported `async fn` be driven by a tokio runtime, which the sync +# engine needs: it is reqwest all the way down. +uniffi = { version = "0.32", features = ["tokio"] } + +# reqwest requires a reactor; uniffi's `async_runtime = "tokio"` needs one to exist. +# rt-multi-thread rather than current_thread: a sync cycle is network-bound and a +# Compose UI may have more than one call in flight. +tokio = { version = "1", features = ["rt-multi-thread"] } + +# Display + Error impls for the error enum uniffi turns into a Kotlin exception. +thiserror = "2" + +[features] +# The Kotlin generator, off by default. +# +# uniffi's `cli` feature drags in clap, askama and goblin — ~15 crates that exist +# only to serve a three-line binary. Until the Android lane lands, this crate is +# compiled on every DESKTOP push (it is a workspace member, so `cargo clippy +# --all-targets` picks it up), and paying for a code generator on a lane that never +# runs one is the wrong trade. `required-features` on the bin means +# `--all-targets` skips it rather than failing. +# +# Generate bindings with: +# cargo run --features bindgen --bin uniffi-bindgen -- generate ... +bindgen = ["uniffi/cli"] + +[[bin]] +name = "uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" +required-features = ["bindgen"] diff --git a/android/ffi/src/bin/uniffi-bindgen.rs b/android/ffi/src/bin/uniffi-bindgen.rs new file mode 100644 index 0000000..e2fbbbc --- /dev/null +++ b/android/ffi/src/bin/uniffi-bindgen.rs @@ -0,0 +1,14 @@ +//! The Kotlin generator, as a binary in THIS workspace. +//! +//! uniffi's generated bindings and the `uniffi` runtime crate linked into the `.so` +//! have to be the same version — they are two halves of one ABI. Running the +//! generator from here guarantees that by construction, because it compiles against +//! the very same dependency. A `cargo install uniffi-bindgen` in the CI image would +//! instead be a second version that has to be kept in step by hand, which is why +//! ci-rust-android deliberately doesn't ship one. +//! +//! Invoked as: cargo run --bin uniffi-bindgen -- generate --library \ +//! --language kotlin --out-dir +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs new file mode 100644 index 0000000..9022c31 --- /dev/null +++ b/android/ffi/src/lib.rs @@ -0,0 +1,439 @@ +//! uniffi bindings: `thoughtsync-core` as seen from Kotlin. +//! +//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop +//! — a thin shim over the shared core, holding no logic of its own. If something here +//! starts making decisions about notes or sync, it belongs in the core where the +//! desktop gets it too (Scribe note 2730). +//! +//! ## Shape +//! +//! One `ThoughtSync` object holds the store and the blob directory, mirroring how +//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the +//! process lifetime, and calls methods on it. +//! +//! ## Async +//! +//! The sync engine is reqwest all the way down, so it needs a reactor. Async methods +//! are exported with `async_runtime = "tokio"`, which uniffi turns into Kotlin +//! `suspend` functions driven by a tokio runtime on the Rust side. +//! +//! Cancellation works, and not by accident: when a coroutine is cancelled uniffi +//! drops the Rust future, and none of the core's async paths hold the store lock +//! across an `await` — a `std::sync::MutexGuard` isn't `Send`, so the compiler has +//! been enforcing that all along. A cancelled sync therefore leaves the store +//! consistent; it simply hasn't stamped `last_sync_at`, which is only written after +//! BOTH halves of a cycle succeed. The next cycle resumes from the stored cursor. +//! +//! ## A known consequence of the release profile +//! +//! The workspace sets `panic = "abort"` (Tauri's profile, for binary size). uniffi +//! would otherwise catch a panic crossing the FFI boundary and raise it in Kotlin as +//! an exception; with `abort` it takes the process down instead. That is the same +//! behaviour the desktop already has, so no surface is worse off than another — but +//! it is a deliberate cost, not an oversight. Revisit if a panic in the core ever +//! turns out to be recoverable enough that a phone should survive it. + +pub mod models; + +use std::path::PathBuf; +use std::sync::Arc; + +use thoughtsync_core::local::{self, Db}; +use thoughtsync_core::sync::blobs::BlobStore; +use thoughtsync_core::sync::{client, compat, engine, push, state}; + +use models::{ + patch_from, Identity, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, RevokeOutcome, + SyncOutcome, SyncStatus, +}; + +uniffi::setup_scaffolding!(); + +/// Everything that can go wrong, as a Kotlin exception. +/// +/// The core reports failures as plain `String`s today, so most of them land in +/// `Store` or `Network` by where they were raised rather than by a distinction the +/// core actually draws. `NotLinked` is the exception and earns its own variant: it +/// is the one failure that is a NORMAL state rather than a fault — an unlinked app is +/// working exactly as intended — and the UI's response is to offer linking, not to +/// show an error. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum CoreError { + /// No server is linked. Not a fault; the app is local-first and this is its + /// resting state. + #[error("this device isn't linked to a server")] + NotLinked, + + /// The on-device store failed. + #[error("{message}")] + Store { message: String }, + + /// Talking to the server failed, or it refused. + #[error("{message}")] + Network { message: String }, +} + +impl CoreError { + fn store(e: impl std::fmt::Display) -> Self { + CoreError::Store { + message: e.to_string(), + } + } + + fn network(e: impl std::fmt::Display) -> Self { + CoreError::Network { + message: e.to_string(), + } + } +} + +/// The client handle: the on-device store plus the attachment directory beside it. +/// +/// Held by Kotlin for the process lifetime. Both halves are `Send + Sync` — the store +/// behind its mutex, the blob store being a path — which is what lets uniffi share +/// one instance across coroutines. +#[derive(uniffi::Object)] +pub struct ThoughtSync { + db: Db, + blobs: BlobStore, +} + +#[uniffi::export] +impl ThoughtSync { + /// Open (creating on first run) the store under `data_dir`, and the attachment + /// directory beside it. + /// + /// `data_dir` comes from Kotlin because only Android knows where its app-private + /// storage is; the core must not guess at a platform path. The layout inside is + /// the core's business and matches the desktop's exactly — `thoughtsync.db` and + /// `blobs/` — so a store is readable by any client that opens it. + #[uniffi::constructor] + pub fn new(data_dir: String) -> Result, CoreError> { + let dir = PathBuf::from(data_dir); + std::fs::create_dir_all(&dir).map_err(CoreError::store)?; + + let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?; + log::info!("local store ready — {}", local::summary(&db)); + + let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?; + Ok(Arc::new(ThoughtSync { db, blobs })) + } + + /// A one-line count summary, for the boot log. + pub fn summary(&self) -> String { + local::summary(&self.db) + } + + // ─────────────────────────────── notes ─────────────────────────────── + + pub fn list_notes(&self, query: NoteQuery) -> Result, CoreError> { + let conn = self.db.conn().map_err(CoreError::store)?; + let notes = local::store::list_notes(&conn, &query.into()).map_err(CoreError::store)?; + Ok(notes.into_iter().map(Note::from).collect()) + } + + pub fn get_note(&self, id: String) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::get_note(&conn, &id) + .map(Note::from) + .map_err(CoreError::store) + } + + pub fn create_note(&self, draft: NoteDraft) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::create_note(&conn, &draft.into()) + .map(Note::from) + .map_err(CoreError::store) + } + + /// Apply a batch of field edits. See `NoteEdit` for why this is a list rather + /// than a struct of nullable fields. + pub fn update_note(&self, id: String, edits: Vec) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::update_note(&conn, &id, &patch_from(edits)) + .map(Note::from) + .map_err(CoreError::store) + } + + pub fn trash_note(&self, id: String) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::trash(&conn, &id) + .map(Note::from) + .map_err(CoreError::store) + } + + pub fn restore_note(&self, id: String) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::restore(&conn, &id) + .map(Note::from) + .map_err(CoreError::store) + } + + // ─────────────────────────────── sync ──────────────────────────────── + + pub fn sync_status(&self) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + state::status(&conn) + .map(SyncStatus::from) + .map_err(CoreError::store) + } + + /// Whether anything is waiting to be sent — so the UI can show an honest + /// "unsynced changes" state without running a sync to find out. + pub fn has_pending(&self) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + push::has_pending(&conn).map_err(CoreError::store) + } +} + +/// Async methods, driven by a tokio runtime and surfaced to Kotlin as `suspend` +/// functions. Split into its own impl block so the runtime attribute — and the fact +/// that everything in here touches the network — is visible at a glance. +#[uniffi::export(async_runtime = "tokio")] +impl ThoughtSync { + /// Ask a server who it is, without committing to anything. Called as the user + /// finishes typing an address, so they see what answered before handing over + /// credentials. + pub async fn probe(&self, url: String) -> Result { + client::probe(&url) + .await + .map(ProbeResult::from) + .map_err(CoreError::network) + } + + /// Pair with a server using an email/password, minting a device token named for + /// this phone. + /// + /// The handshake runs FIRST, and an incompatible server aborts before any + /// credential is sent — an incompatible server is exactly the case where a later + /// failure would be hardest to attribute. + pub async fn link_with_password( + &self, + url: String, + email: String, + password: String, + device_name: String, + ) -> Result { + let probe = client::probe(&url).await.map_err(CoreError::network)?; + if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility { + return Err(CoreError::Network { + message: reason.clone(), + }); + } + let (token, identity) = + client::device_login(&probe.base_url, &email, &password, &device_name) + .await + .map_err(CoreError::network)?; + self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?; + Ok(identity.into()) + } + + /// Pair using a device token pasted from the web app — for anyone who would + /// rather not type a password into an app, or whose account is behind SSO. + /// + /// The token is verified before it is stored, so a copy/paste slip fails here + /// rather than at the next sync. + pub async fn link_with_token(&self, url: String, token: String) -> Result { + let probe = client::probe(&url).await.map_err(CoreError::network)?; + if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility { + return Err(CoreError::Network { + message: reason.clone(), + }); + } + let identity = client::fetch_identity(&probe.base_url, &token) + .await + .map_err(CoreError::network)?; + self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?; + Ok(identity.into()) + } + + /// Stop syncing, and retire this device's token on the server. + /// + /// The local half is unconditional. Someone unlinking because the phone is being + /// sold or handed on must not be held to it by a server that is offline or gone, + /// so the revoke is attempted first, its outcome returned for the UI to report + /// honestly, and the link cleared either way. + pub async fn unlink(&self) -> Result { + // Read and release before the network call: a std MutexGuard isn't Send, so + // it cannot be held across an await, and holding the store through a + // round-trip would freeze every note operation in the UI. + let link = { + let conn = self.db.conn().map_err(CoreError::store)?; + let current = state::read(&conn).map_err(CoreError::store)?; + current.server_url.zip(current.device_token) + }; + let revoked = match &link { + Some((base_url, token)) => client::revoke_self(base_url, token).await, + None => client::RevokeOutcome::Skipped, + }; + + let conn = self.db.conn().map_err(CoreError::store)?; + state::clear_link(&conn).map_err(CoreError::store)?; + log::info!("unlinked from server (server-side token: {revoked:?})"); + Ok(revoked.into()) + } + + /// Run one full sync: push local changes, then pull the server's. + /// + /// The only sync entry point, on purpose. Push and pull exist separately inside + /// the core, but offering a bare "pull" would let the UI overwrite unsent local + /// edits — the ordering isn't a suggestion, it's what keeps them. + pub async fn sync_now(&self) -> Result { + let (base_url, token) = self.credentials()?; + engine::run_cycle(&self.db, &self.blobs, &base_url, &token) + .await + .map(SyncOutcome::from) + .map_err(CoreError::network) + } +} + +/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` +/// block names, so these stay Rust-side. +impl ThoughtSync { + /// The server URL + token, or the `NotLinked` state. Every networked call needs + /// exactly this, and none of them may hold the lock past it. + fn credentials(&self) -> Result<(String, String), CoreError> { + let conn = self.db.conn().map_err(CoreError::store)?; + let current = state::read(&conn).map_err(CoreError::store)?; + match (current.server_url, current.device_token) { + (Some(url), Some(token)) => Ok((url, token)), + _ => Err(CoreError::NotLinked), + } + } + + /// Persist a fresh link, adopting the server's retention window at the same time + /// so the Trash view stops counting down against this device's offline default + /// the moment it is no longer the policy in force. + fn store_link( + &self, + base_url: &str, + token: &str, + retention_days: Option, + ) -> Result<(), CoreError> { + let conn = self.db.conn().map_err(CoreError::store)?; + state::set_link(&conn, base_url, token).map_err(CoreError::store)?; + if let Some(days) = retention_days { + state::set_server_retention(&conn, days as i64).map_err(CoreError::store)?; + } + log::info!("linked to {base_url}"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A scratch directory unique to this process and call. + /// + /// Process id + a counter rather than a uuid dependency: the FFI crate has no + /// business pulling one in to name a temp folder, and this is the same approach + /// the desktop's updater tests settled on. + fn scratch_dir() -> String { + use std::sync::atomic::{AtomicU32, Ordering}; + static NEXT: AtomicU32 = AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!( + "thoughtsync-ffi-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + dir.to_string_lossy().into_owned() + } + + fn draft(title: &str, body: &str) -> NoteDraft { + NoteDraft { + title: title.to_string(), + body: body.to_string(), + color: "default".to_string(), + kind: None, + items: None, + } + } + + /// The round trip the Android skeleton has to make: open a store in a directory + /// that doesn't exist yet, write a note, read it back through the FFI types. + /// Proving it here means a failure on device is an Android problem, not a + /// binding problem. + #[test] + fn creates_a_store_and_round_trips_a_note() { + let dir = scratch_dir(); + let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); + + let created = app + .create_note(draft("Groceries", "milk")) + .expect("create should succeed"); + assert_eq!(created.title.as_deref(), Some("Groceries")); + assert_eq!(created.body, "milk"); + + let fetched = app + .get_note(created.id.clone()) + .expect("get should succeed"); + assert_eq!(fetched.id, created.id); + assert_eq!(fetched.display_title, "Groceries"); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// A body-only note still has to be nameable — that is what `display_title` is + /// for, and the Android board relies on it exactly as the desktop does. + #[test] + fn body_only_notes_still_have_a_display_title() { + let dir = scratch_dir(); + let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); + + let created = app + .create_note(draft("", "just a thought")) + .expect("create should succeed"); + assert_eq!(created.title, None); + assert_eq!(created.display_title, "just a thought"); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// Clearing a field and setting one are different edits, and the difference has + /// to survive the trip through the patch object. + #[test] + fn edits_can_both_set_and_clear_a_title() { + let dir = scratch_dir(); + let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); + let note = app.create_note(draft("First", "body")).expect("create"); + + let renamed = app + .update_note( + note.id.clone(), + vec![NoteEdit::Title { + value: "Second".to_string(), + }], + ) + .expect("rename"); + assert_eq!(renamed.title.as_deref(), Some("Second")); + + let cleared = app + .update_note(note.id.clone(), vec![NoteEdit::ClearTitle]) + .expect("clear"); + assert_eq!( + cleared.title, None, + "ClearTitle must null the column, not set it to an empty string — the \ + distinction is why NoteEdit is a list rather than a struct of options" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// An unlinked app is a normal, working app. Asking it to sync is the one + /// failure that isn't a fault, and it has to arrive as `NotLinked` so the UI can + /// offer linking rather than show an error. + #[test] + fn syncing_unlinked_reports_not_linked() { + let dir = scratch_dir(); + let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); + + let status = app.sync_status().expect("status should read"); + assert!(!status.linked); + assert_eq!(status.server_url, None); + + assert!(matches!(app.credentials(), Err(CoreError::NotLinked))); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs new file mode 100644 index 0000000..fb5f34e --- /dev/null +++ b/android/ffi/src/models.rs @@ -0,0 +1,675 @@ +//! The types that cross into Kotlin. +//! +//! These MIRROR `thoughtsync_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 thoughtsync_core::local::models as core_models; +use thoughtsync_core::sync::client as core_client; +use thoughtsync_core::sync::compat as core_compat; +use thoughtsync_core::sync::engine as core_engine; +use thoughtsync_core::sync::pull as core_pull; +use thoughtsync_core::sync::push as core_push; +use thoughtsync_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, + pub title: Option, + /// Title if set, else the first body line — always present, so a body-only note + /// is still nameable. Derived by the core, never stored. + pub display_title: String, + pub body: String, + pub color: String, + pub kind: String, + pub position: i64, + pub pinned: bool, + pub archived: bool, + pub trashed: bool, + pub deleted_at: Option, + pub remind_at: Option, + pub recurrence: Option, + pub labels: Vec, + pub items: Vec, + pub attachments: Vec, + pub previews: Vec, + pub created_at: Option, + pub updated_at: Option, +} + +#[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, + pub mime: String, + pub size: Option, + pub sha256: Option, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct LinkPreview { + pub id: String, + pub url: String, + pub title: Option, + pub description: Option, + pub image_url: Option, + pub site_name: Option, +} + +impl From for Note { + fn from(value: core_models::Note) -> Self { + // Exhaustive on purpose — see the module header. + let core_models::Note { + id, + title, + display_title, + body, + color, + kind, + position, + pinned, + archived, + trashed, + deleted_at, + remind_at, + recurrence, + labels, + items, + attachments, + previews, + created_at, + updated_at, + } = value; + Note { + id, + title, + display_title, + body, + color, + kind, + 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, + } + } +} + +impl From 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 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 for Attachment { + fn from(value: core_models::Attachment) -> Self { + let core_models::Attachment { + id, + url, + filename, + mime, + size, + sha256, + } = value; + Attachment { + id, + url, + filename, + mime, + size, + sha256, + } + } +} + +impl From 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, + } + } +} + +// ───────────────────────────── 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, + pub sort: Option, + pub facets: Option, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct NoteFacets { + pub q: Option, + pub color: Option, + pub kind: Option, + pub label: Option>, + pub has_reminder: Option, + pub has_attachment: Option, + pub created_after: Option, + pub created_before: Option, +} + +impl From 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 for core_models::Facets { + fn from(value: NoteFacets) -> Self { + let NoteFacets { + q, + color, + kind, + label, + has_reminder, + has_attachment, + created_after, + created_before, + } = value; + core_models::Facets { + q, + color, + kind, + label, + has_reminder, + has_attachment, + created_after, + created_before, + } + } +} + +/// A new note. +#[derive(Debug, Clone, uniffi::Record)] +pub struct NoteDraft { + pub title: String, + pub body: String, + /// "default" unless the user picked a colour. + pub color: String, + pub kind: Option, + /// Checklist lines, for `kind = "checklist"`. + pub items: Option>, +} + +impl From for core_models::NoteCreateInput { + fn from(value: NoteDraft) -> Self { + let NoteDraft { + title, + body, + color, + kind, + items, + } = value; + core_models::NoteCreateInput { + title, + body, + color, + kind, + 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. `title: +/// null` in a data class is indistinguishable from `title` unset, so the editor +/// could never clear a title. 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 { + Title { value: String }, + ClearTitle, + Body { value: String }, + Color { value: String }, + Kind { 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::Title { value } => ("title", Value::String(value)), + NoteEdit::ClearTitle => ("title", Value::Null), + NoteEdit::Body { value } => ("body", Value::String(value)), + NoteEdit::Color { value } => ("color", Value::String(value)), + NoteEdit::Kind { value } => ("kind", 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 title, +/// then clear title" would expect. +pub fn patch_from(edits: Vec) -> 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, + pub last_cursor: i64, + pub last_sync_at: Option, +} + +impl From 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, + pub version: Option, + pub trash_retention_days: Option, + 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, + }, + /// 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 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 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 for Identity { + fn from(value: core_client::Identity) -> Self { + let core_client::Identity { + id, + email, + display_name, + } = value; + Identity { + id, + email, + display_name, + } + } +} + +/// 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 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, +} + +#[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 for PushSummary { + fn from(value: core_push::PushSummary) -> Self { + let core_push::PushSummary { + batches, + sent, + created, + applied, + kept, + noop, + rejected, + errors, + } = 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, + } + } +} + +impl From 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 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::Title { + value: "x".to_string(), + }]); + assert_eq!(set["title"], serde_json::json!("x")); + + let cleared = patch_from(vec![NoteEdit::ClearTitle]); + assert!( + cleared["title"].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::Title { + value: "first".to_string(), + }, + NoteEdit::ClearTitle, + ]); + assert!(patch["title"].is_null()); + } +} diff --git a/android/ffi/uniffi.toml b/android/ffi/uniffi.toml new file mode 100644 index 0000000..8d4c9ce --- /dev/null +++ b/android/ffi/uniffi.toml @@ -0,0 +1,5 @@ +# Where the generated Kotlin lands. Matches the app's package so the bindings are +# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias. +[bindings.kotlin] +package_name = "com.fabledsword.thoughtsync.core" +cdylib_name = "thoughtsync_ffi" diff --git a/core/src/local/mod.rs b/core/src/local/mod.rs index 4b68ed5..d153a2c 100644 --- a/core/src/local/mod.rs +++ b/core/src/local/mod.rs @@ -21,6 +21,26 @@ use rusqlite::Connection; /// it in the uniffi object. pub struct Db(pub Mutex); +impl Db { + /// Lock the store, reporting a poisoned lock as a message rather than a panic. + /// + /// Every consumer was writing `db.0.lock().map_err(|e| e.to_string())?` at each + /// call site. Beyond the repetition, that spelling forces the caller to NAME + /// `rusqlite::Connection` in any helper that returns the guard — which would make + /// rusqlite a dependency of a layer whose whole point is not to know what the + /// store is made of. Returning it from here means callers can bind the guard by + /// inference and never name the type. + /// + /// A poisoned lock means some earlier call panicked while holding it. The store + /// is not necessarily corrupt, but this connection can't be trusted blind, so it + /// surfaces as an error the UI can show instead of a second panic. + pub fn conn(&self) -> Result, String> { + self.0 + .lock() + .map_err(|_| "the local store lock was poisoned by an earlier panic".to_string()) + } +} + /// Open (creating if needed) the database at `path` and bring it to the latest schema. pub fn open(path: &Path) -> rusqlite::Result { let conn = Connection::open(path)?;