//! 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, Label, 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)] // FLAT, so the Kotlin side gets the message on `Throwable` where it belongs. // // Without this, uniffi generates an exception subclass with a `message` PROPERTY // per variant — which collides with `Throwable.message` and fails to compile: // "'message' hides member of supertype 'Throwable' and needs an 'override' // modifier". Renaming the field would dodge the collision but leave // `e.message` null in Kotlin, so every call site would have to know the variant // just to read the text. // // Flat keeps what actually matters: each variant is still its own Kotlin // subclass, so `catch (e: CoreException.NotLinked)` still works and a `when` is // still exhaustive. Only the FIELDS stop crossing, and the Display string — // which is the field, for every variant that has one — comes through as the // exception message. #[uniffi(flat_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) } /// Full-text search across titles, bodies and checklist items. /// /// The core owns the query — it searches the same columns the desktop and web /// search, so "what matches" cannot drift between surfaces. Filtering the /// board list in Kotlin would have been less code and a different product. pub fn search_notes(&self, query: String) -> Result, CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; let notes = local::store::search(&conn, &query).map_err(CoreError::store)?; Ok(notes.into_iter().map(Note::from).collect()) } /// Notes carrying a reminder, soonest first. /// /// A dedicated call rather than a board `view`, because that is how the core /// models it — `list_notes` only understands trashed/archived/default. pub fn reminder_notes(&self) -> Result, CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; let notes = local::store::reminders(&conn).map_err(CoreError::store)?; Ok(notes.into_iter().map(Note::from).collect()) } /// Every label with its note count, for the navigation drawer. pub fn list_labels(&self) -> Result, CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; let labels = local::store::list_labels(&conn).map_err(CoreError::store)?; Ok(labels.into_iter().map(Label::from).collect()) } 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(); } }