CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME stays — search results, export filenames and the command palette all need one — but nothing is typed into it any more. `display_title` is now the first non-empty line of the body, falling back to the first checklist item. That fallback is what step 2 bought, and the reason this could not go first: a checklist had no body to be named from, so the title was its only name. Now every note has a body, and a note that is only a checklist is named by its first item. Gone everywhere: the column and note_revisions.title (0026), the field on the core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7), `normalize_title`, the wire field, the FFI record and `NoteEdit::Title` / `ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and the Android title field in both the compose sheet and the editor. **The search vector had to be rebuilt, not just left alone.** `notes.search_vector` is a STORED GENERATED column whose expression names `title` — Postgres refuses to drop a column another generated column depends on. It is dropped and recreated over `display_title` at weight A, which keeps the original intent: a note's NAME ranks above the rest of its body. **An imported title becomes the note's first body line.** Keep notes carry one, and so does any ThoughtSync export taken before this. Dropping it would silently lose text someone wrote; folding it in puts it exactly where a name now lives, so the note arrives named as it was. Skipped when the body already opens with that line, so re-importing an export this code produced doesn't stack duplicates. Two smaller things fell out. The Android editor loses its bold first field — one weight throughout, because the first line is the note's name but not a different KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s justification comment moved to `ClearRemindAt`, which is now the surviving example of why NoteEdit is a list rather than a struct of options. Protocol note corrected to say what actually shipped: v2 is "no kind, no title", one bump for the pair. Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests all green before pushing. It caught four things — orphaned serde attributes where fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview still has one), nine retention fixtures inserting a dropped column, and four rustfmt diffs.
861 lines
35 KiB
Rust
861 lines
35 KiB
Rust
//! 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, ClientUpdate, 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<Arc<Self>, 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<Vec<Note>, 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<Note, CoreError> {
|
|
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<Note, CoreError> {
|
|
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<NoteEdit>) -> Result<Note, CoreError> {
|
|
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<Vec<Note>, 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<Vec<Note>, 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<Vec<Label>, 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<Note, CoreError> {
|
|
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<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::restore(&conn, &id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Remove a note permanently.
|
|
///
|
|
/// Returns nothing, unlike every other mutation here: there is no note left to
|
|
/// return. The core also records a pending delete, so a linked device tells the
|
|
/// server rather than having the next pull resurrect the row.
|
|
pub fn delete_note_forever(&self, id: String) -> Result<(), CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
|
|
}
|
|
|
|
// ──────────────────────────── checklist items ────────────────────────────
|
|
//
|
|
// Every one of these returns the whole reloaded note rather than the item it
|
|
// touched. That is the core's shape, and it is the right one for a UI: ticking
|
|
// a box changes `updated_at` and can change what the board shows, so handing
|
|
// back only the item would leave Kotlin to guess at the rest.
|
|
|
|
pub fn add_item(&self, note_id: String, text: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::add_item(&conn, ¬e_id, &text)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Retitle one item.
|
|
///
|
|
/// Split from `set_item_checked` rather than exposing the core's
|
|
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an
|
|
/// optional-field struct cannot say "leave this alone" in Kotlin without
|
|
/// colliding with "set it to null", and two unambiguous calls beat one
|
|
/// ambiguous one when each is three lines.
|
|
pub fn set_item_text(
|
|
&self,
|
|
note_id: String,
|
|
item_id: String,
|
|
text: String,
|
|
) -> Result<Note, CoreError> {
|
|
self.patch_item(¬e_id, &item_id, serde_json::json!({ "text": text }))
|
|
}
|
|
|
|
pub fn set_item_checked(
|
|
&self,
|
|
note_id: String,
|
|
item_id: String,
|
|
checked: bool,
|
|
) -> Result<Note, CoreError> {
|
|
self.patch_item(
|
|
¬e_id,
|
|
&item_id,
|
|
serde_json::json!({ "checked": checked }),
|
|
)
|
|
}
|
|
|
|
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::delete_item(&conn, ¬e_id, &item_id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
// ─────────────────────────────── reminders ───────────────────────────────
|
|
|
|
/// Clear the reminder, marking it dealt with.
|
|
///
|
|
/// Distinct from `NoteEdit::ClearRemindAt` even though today they do the same
|
|
/// thing: the core reserves this one for "the reminder fired and is finished",
|
|
/// which is where recurrence advancement lands when it is built. A UI that
|
|
/// called the generic clear instead would silently stop recurring reminders
|
|
/// from recurring the day that changes.
|
|
pub fn complete_reminder(&self, id: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::complete_reminder(&conn, &id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Push the reminder out by `minutes` from now.
|
|
///
|
|
/// The core computes the new instant from its own clock rather than taking one
|
|
/// from the caller — so "in an hour" means the same thing on every surface,
|
|
/// and a phone with a skewed clock can't write a reminder the server reads as
|
|
/// already past.
|
|
pub fn snooze_reminder(&self, id: String, minutes: i64) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::snooze_reminder(&conn, &id, minutes)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
// ───────────────────────────────── labels ────────────────────────────────
|
|
|
|
/// Replace the note's MANUAL labels.
|
|
///
|
|
/// `#tag` labels are owned by the body text and the core re-derives them on
|
|
/// every body edit, so they are deliberately untouched here. A picker that
|
|
/// sent the full visible set would strip a tag label the text still mandates —
|
|
/// and the next keystroke in the body would put it straight back, which is the
|
|
/// kind of fight a UI should never pick with its store.
|
|
pub fn set_note_labels(
|
|
&self,
|
|
note_id: String,
|
|
label_ids: Vec<String>,
|
|
) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::set_labels(&conn, ¬e_id, &label_ids)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Find or create a label by name, returning it either way.
|
|
///
|
|
/// Find-or-create rather than create: the core matches case-insensitively, so
|
|
/// typing "Errands" when "errands" exists has to attach the existing label
|
|
/// instead of minting a near-duplicate that then diverges on colour.
|
|
pub fn create_label(&self, name: String) -> Result<Label, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::create_label(&conn, &name)
|
|
.map(Label::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
// ─────────────────────────────── sync ────────────────────────────────
|
|
|
|
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
|
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<bool, CoreError> {
|
|
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<ProbeResult, CoreError> {
|
|
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<Identity, CoreError> {
|
|
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<Identity, CoreError> {
|
|
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<RevokeOutcome, CoreError> {
|
|
// 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.
|
|
/// The Android client the linked server is offering, if any.
|
|
///
|
|
/// `None` covers two different-looking situations that are one answer to the
|
|
/// app: this server has no client, or it has one and it is not newer than what
|
|
/// is already installed. Comparing here rather than in Kotlin keeps the rule —
|
|
/// version CODE decides, never the name — in the layer that also has to get it
|
|
/// right for the desktop.
|
|
pub async fn client_update(
|
|
&self,
|
|
installed_version_code: i64,
|
|
) -> Result<Option<ClientUpdate>, CoreError> {
|
|
let (base_url, token) = self.credentials()?;
|
|
let release = client::fetch_client_release(&base_url, &token)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
Ok(release
|
|
.filter(|r| r.version_code > installed_version_code)
|
|
.map(ClientUpdate::from))
|
|
}
|
|
|
|
/// Download that client to `dest_path`, verified.
|
|
///
|
|
/// Takes the destination rather than choosing one: only Android knows a
|
|
/// directory its own package installer can read from, and the core has no
|
|
/// business guessing at platform paths — the same reason `ThoughtSync::new`
|
|
/// takes a data dir.
|
|
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
|
|
let (base_url, token) = self.credentials()?;
|
|
let release = client::fetch_client_release(&base_url, &token)
|
|
.await
|
|
.map_err(CoreError::network)?
|
|
// Re-read rather than trusting what the caller was shown: the server
|
|
// may have published a new build between the check and the tap, and
|
|
// downloading against a stale digest would fail verification on bytes
|
|
// that are perfectly good.
|
|
.ok_or_else(|| {
|
|
CoreError::network("This server no longer has an Android client.".to_string())
|
|
})?;
|
|
client::download_client(
|
|
&base_url,
|
|
&token,
|
|
&release,
|
|
std::path::Path::new(&dest_path),
|
|
)
|
|
.await
|
|
.map_err(CoreError::network)
|
|
}
|
|
|
|
pub async fn sync_now(&self) -> Result<SyncOutcome, CoreError> {
|
|
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 {
|
|
/// Apply a `{text}` or `{checked}` patch to one checklist item.
|
|
///
|
|
/// The two public setters differ only in the key they write, and the lock +
|
|
/// convert + map-error dance around it is identical, so it lives once here.
|
|
fn patch_item(
|
|
&self,
|
|
note_id: &str,
|
|
item_id: &str,
|
|
changes: serde_json::Value,
|
|
) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::update_item(&conn, note_id, item_id, &changes)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// 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<u32>,
|
|
) -> 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(body: &str) -> NoteDraft {
|
|
NoteDraft {
|
|
body: body.to_string(),
|
|
color: "default".to_string(),
|
|
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\nmilk"))
|
|
.expect("create should succeed");
|
|
assert_eq!(created.body, "Groceries\nmilk");
|
|
|
|
let fetched = app
|
|
.get_note(created.id.clone())
|
|
.expect("get should succeed");
|
|
assert_eq!(fetched.id, created.id);
|
|
// The NAME is the first line — there is no title field to have set (M13 step 3).
|
|
assert_eq!(fetched.display_title, "Groceries");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Every note 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 a_note_is_named_by_its_first_line() {
|
|
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.display_title, "just a thought");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The hole that made removing the title unsafe until checklists stopped being
|
|
/// their own kind of thing: a note with no body text still needs a name.
|
|
#[test]
|
|
fn a_note_with_only_items_is_named_by_its_first_item() {
|
|
let dir = scratch_dir();
|
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let created = app
|
|
.create_note(NoteDraft {
|
|
body: String::new(),
|
|
color: "default".to_string(),
|
|
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
|
})
|
|
.expect("create should succeed");
|
|
assert_eq!(created.display_title, "milk");
|
|
|
|
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();
|
|
}
|
|
|
|
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle
|
|
/// it, drop it. Each call returns the reloaded note, which is what the UI
|
|
/// splices back into the board rather than re-querying.
|
|
#[test]
|
|
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
|
|
let dir = scratch_dir();
|
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app
|
|
.create_note(NoteDraft {
|
|
body: "Packing".to_string(),
|
|
color: "default".to_string(),
|
|
items: Some(vec!["socks".to_string()]),
|
|
})
|
|
.expect("create");
|
|
assert_eq!(note.items.len(), 1);
|
|
|
|
let with_two = app
|
|
.add_item(note.id.clone(), "charger".to_string())
|
|
.expect("add");
|
|
assert_eq!(with_two.items.len(), 2);
|
|
// Appended, not prepended — a new row belongs at the bottom of the list the
|
|
// user is looking at.
|
|
assert_eq!(with_two.items[1].text, "charger");
|
|
|
|
let item_id = with_two.items[1].id.clone();
|
|
let ticked = app
|
|
.set_item_checked(note.id.clone(), item_id.clone(), true)
|
|
.expect("tick");
|
|
assert!(ticked.items[1].checked);
|
|
assert_eq!(
|
|
ticked.items[1].text, "charger",
|
|
"ticking a box must not disturb its text — the two setters write \
|
|
different columns and neither may clear the other"
|
|
);
|
|
|
|
let renamed = app
|
|
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string())
|
|
.expect("rename");
|
|
assert_eq!(renamed.items[1].text, "usb-c cable");
|
|
assert!(
|
|
renamed.items[1].checked,
|
|
"and the same in the other direction"
|
|
);
|
|
|
|
let trimmed = app
|
|
.delete_item(note.id.clone(), item_id)
|
|
.expect("delete item");
|
|
assert_eq!(trimmed.items.len(), 1);
|
|
assert_eq!(trimmed.items[0].text, "socks");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A `#tag` in the body owns its label. The picker replaces MANUAL labels only,
|
|
/// so sending an empty set must not strip one the text still mandates —
|
|
/// otherwise the next body edit would re-derive it and the UI would appear to
|
|
/// fight itself.
|
|
#[test]
|
|
fn setting_labels_leaves_tag_derived_ones_alone() {
|
|
let dir = scratch_dir();
|
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let note = app
|
|
.create_note(draft("Trip\nbook the ferry #travel"))
|
|
.expect("create");
|
|
assert_eq!(
|
|
note.labels.len(),
|
|
1,
|
|
"the #tag should have attached a label"
|
|
);
|
|
assert!(note.labels[0].via_tag);
|
|
|
|
let errands = app
|
|
.create_label("errands".to_string())
|
|
.expect("create label");
|
|
let tagged = app
|
|
.set_note_labels(note.id.clone(), vec![errands.id.clone()])
|
|
.expect("set labels");
|
|
assert_eq!(tagged.labels.len(), 2);
|
|
|
|
let cleared = app
|
|
.set_note_labels(note.id.clone(), vec![])
|
|
.expect("clear manual labels");
|
|
assert_eq!(cleared.labels.len(), 1);
|
|
assert!(cleared.labels[0].via_tag);
|
|
|
|
// Find-or-create, not create: a second "Errands" must be the same label,
|
|
// or the picker mints near-duplicates that then diverge on colour.
|
|
let again = app
|
|
.create_label("Errands".to_string())
|
|
.expect("create label again");
|
|
assert_eq!(again.id, errands.id);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Deleting forever has to actually remove the row, and the note must then be
|
|
/// unreadable rather than merely hidden.
|
|
#[test]
|
|
fn deleting_forever_removes_the_note() {
|
|
let dir = scratch_dir();
|
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
|
|
|
app.delete_note_forever(note.id.clone())
|
|
.expect("delete forever");
|
|
assert!(
|
|
app.get_note(note.id.clone()).is_err(),
|
|
"a permanently deleted note must not still load"
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Snooze writes a future instant from the CORE's clock; complete clears it.
|
|
#[test]
|
|
fn reminders_can_be_snoozed_and_completed() {
|
|
let dir = scratch_dir();
|
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app.create_note(draft("Call back")).expect("create");
|
|
assert_eq!(note.remind_at, None);
|
|
|
|
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
|
|
let at = snoozed.remind_at.expect("snoozing must set a reminder");
|
|
let parsed = chrono_free_parse(&at);
|
|
assert!(
|
|
parsed > 0,
|
|
"the reminder must be a parseable RFC3339 instant, got {at:?}"
|
|
);
|
|
|
|
let done = app.complete_reminder(note.id.clone()).expect("complete");
|
|
assert_eq!(done.remind_at, None);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The path the notification's Done button takes.
|
|
///
|
|
/// Completing a RECURRING reminder must move it, not end it — this is the
|
|
/// behaviour the web has had all along and the clients did not, which made
|
|
/// "Done" on a daily reminder quietly the last time it ever fired.
|
|
#[test]
|
|
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
|
let dir = scratch_dir();
|
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app.create_note(draft("Water the plants")).expect("create");
|
|
|
|
let armed = app
|
|
.update_note(
|
|
note.id.clone(),
|
|
vec![
|
|
NoteEdit::RemindAt {
|
|
value: "2026-07-01T09:00:00.000Z".into(),
|
|
},
|
|
NoteEdit::Recurrence {
|
|
value: "daily".into(),
|
|
},
|
|
],
|
|
)
|
|
.expect("arm a daily reminder");
|
|
assert_eq!(armed.recurrence.as_deref(), Some("daily"));
|
|
|
|
let done = app.complete_reminder(note.id.clone()).expect("complete");
|
|
let next = done
|
|
.remind_at
|
|
.expect("a daily reminder must still have a next occurrence");
|
|
assert!(
|
|
next.as_str() > "2026-07-01T09:00:00.000Z",
|
|
"it must move FORWARD, got {next:?}"
|
|
);
|
|
assert!(
|
|
next.ends_with("T09:00:00.000Z"),
|
|
"the time of day is what was asked for and must survive, got {next:?}"
|
|
);
|
|
assert_eq!(
|
|
done.recurrence.as_deref(),
|
|
Some("daily"),
|
|
"the rule outlives the occurrence"
|
|
);
|
|
|
|
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
|
|
// invisibly on a note with no reminder.
|
|
let once = app.create_note(draft("Post the letter")).expect("create");
|
|
app.update_note(
|
|
once.id.clone(),
|
|
vec![NoteEdit::RemindAt {
|
|
value: "2026-07-01T09:00:00.000Z".into(),
|
|
}],
|
|
)
|
|
.expect("arm a one-off");
|
|
let finished = app.complete_reminder(once.id.clone()).expect("complete");
|
|
assert_eq!(finished.remind_at, None);
|
|
assert_eq!(finished.recurrence, None);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
|
|
/// crate's dev-dependencies to assert one field is well-formed.
|
|
fn chrono_free_parse(raw: &str) -> usize {
|
|
if raw.len() >= 20 && raw.as_bytes()[4] == b'-' && raw.contains('T') {
|
|
raw.len()
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
}
|