android: bind the core to Kotlin through uniffi (M12 step 4)
`android/ffi` is to Android what `desktop/src-tauri/src/commands/` is to the
desktop: a shim over the shared core holding no logic of its own. Third workspace
member, so the desktop lane's `cargo clippy --all-targets` compiles and lints it
— which until the Android lane lands (step 5) is the only thing that does.
Three decisions worth stating.
MIRRORED RECORDS, NOT DERIVES ON THE CORE. The core's model structs are serde
shapes contracted with the shared Vue frontend, and one of them holds a
serde_json::Value, which has no uniffi representation. Hanging uniffi derives on
them would couple two unrelated consumers to one definition. The cost of
mirroring is drift — an Android client quietly missing a field the desktop
gained — so every conversion destructures the core struct exhaustively. Add a
field to core::local::models::Note and this crate stops compiling until Android
is told what to do with it.
NoteEdit IS A LIST, NOT A STRUCT OF NULLABLE FIELDS. The store's patch format
distinguishes three states: leave alone, set, and clear to null. Kotlin cannot
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 give Kotlin a sealed class.
ASYNC IS TOKIO-BACKED, AND CANCELLATION ALREADY WORKED. Exported async methods
become Kotlin suspend functions. When a coroutine is cancelled uniffi drops the
future, and no async path in the core holds the store lock across an await —
a std MutexGuard isn't Send, so the compiler has been enforcing that all along.
A cancelled sync leaves the store consistent and simply hasn't stamped
last_sync_at, which is only written after both halves of a cycle succeed.
Also here:
* core gains Db::conn(). Every consumer was writing
`db.0.lock().map_err(|e| e.to_string())?` by hand, and worse, any helper
returning the guard had to NAME rusqlite::Connection — which would have made
rusqlite a dependency of a layer whose whole point is not knowing what the
store is made of. Same trap as the update.rs test module in step 1.
* The uniffi `cli` feature is gated behind our own `bindgen` feature. It drags
in clap, askama and goblin for a three-line binary, and the desktop lane
should not compile a code generator it never runs.
* The bindgen binary lives in this workspace on purpose: generated bindings and
the linked uniffi runtime are two halves of one ABI, and compiling the
generator against the same dependency keeps them in step by construction.
That is why ci-rust-android ships no uniffi-bindgen.
Tests cover the round trip the Android skeleton needs (open a store in a
directory that does not exist yet, write a note, read it back), that a body-only
note still has a display_title, that set and clear are genuinely different
edits, and that an unlinked app reports NotLinked rather than an error.
Known and deliberate: the workspace sets panic = "abort", so a panic crossing the
FFI aborts instead of arriving in Kotlin as an exception. Same behaviour the
desktop already has; noted in the crate header rather than silently changed.
Scribe #2733.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,11 @@ on:
|
|||||||
# crate since the Android client binds the same code, so a change there is a
|
# 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.
|
# change to this app even though nothing under desktop/ moved.
|
||||||
- "core/**"
|
- "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.
|
# The workspace manifest and lockfile, which now live at the repo root.
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
|
|||||||
Generated
+372
-2
@@ -70,6 +70,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
@@ -85,6 +91,72 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"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]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -132,6 +204,15 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
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]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -280,6 +361,16 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "cargo_metadata"
|
name = "cargo_metadata"
|
||||||
version = "0.19.2"
|
version = "0.19.2"
|
||||||
@@ -287,7 +378,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
|
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"camino",
|
"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",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -359,6 +464,45 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -953,6 +1097,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -1263,6 +1416,17 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "gtk"
|
name = "gtk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -1996,6 +2160,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minisign-verify"
|
name = "minisign-verify"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -2091,6 +2261,16 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2543,6 +2723,12 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plain"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plist"
|
name = "plist"
|
||||||
version = "1.10.0"
|
version = "1.10.0"
|
||||||
@@ -3076,6 +3262,26 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -3356,6 +3562,12 @@ version = "1.15.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smawk"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -3420,6 +3632,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache"
|
name = "string_cache"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3842,7 +4060,7 @@ checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"brotli",
|
"brotli",
|
||||||
"cargo_metadata",
|
"cargo_metadata 0.19.2",
|
||||||
"ctor",
|
"ctor",
|
||||||
"dom_query",
|
"dom_query",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -3905,6 +4123,15 @@ dependencies = [
|
|||||||
"new_debug_unreachable",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3974,6 +4201,18 @@ dependencies = [
|
|||||||
"thoughtsync-core",
|
"thoughtsync-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thoughtsync-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"thoughtsync-core",
|
||||||
|
"tokio",
|
||||||
|
"uniffi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.55"
|
version = "0.3.55"
|
||||||
@@ -4355,6 +4594,128 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -4655,6 +5016,15 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
# Rust workspace. Two members today: the framework-free client core, and the Tauri
|
# Rust workspace. The framework-free client core, and the two shims that wrap it:
|
||||||
# desktop app that wraps it. The Android client becomes a third consumer of `core`
|
# the Tauri desktop app and the uniffi bindings the native Android client loads.
|
||||||
# through uniffi (Scribe note 2730) — which is the reason the core is a crate at all
|
# Neither shim owns the core — that is the reason it is a crate at all rather than a
|
||||||
# rather than a module inside the desktop app.
|
# module inside the desktop app (Scribe note 2730).
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
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
|
# Shared pins, so two consumers of the core cannot drift onto different versions of
|
||||||
# the same dependency and resolve differently.
|
# the same dependency and resolve differently.
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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 <path/to/.so> \
|
||||||
|
//! --language kotlin --out-dir <app/src/main/java>
|
||||||
|
fn main() {
|
||||||
|
uniffi::uniffi_bindgen_main()
|
||||||
|
}
|
||||||
@@ -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<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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────── 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.
|
||||||
|
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 {
|
||||||
|
/// 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(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
pub remind_at: Option<String>,
|
||||||
|
pub recurrence: Option<String>,
|
||||||
|
pub labels: Vec<NoteLabel>,
|
||||||
|
pub items: Vec<ChecklistItem>,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
pub previews: Vec<LinkPreview>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteLabel {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub color: String,
|
||||||
|
/// True when attached because of a `#tag` in the body, so the UI can show it is
|
||||||
|
/// owned by the text and not independently removable.
|
||||||
|
pub via_tag: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ChecklistItem {
|
||||||
|
pub id: String,
|
||||||
|
pub text: String,
|
||||||
|
pub checked: bool,
|
||||||
|
pub position: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub mime: String,
|
||||||
|
pub size: Option<i64>,
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct LinkPreview {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::Note> for Note {
|
||||||
|
fn from(value: core_models::Note) -> Self {
|
||||||
|
// Exhaustive on purpose — see the module header.
|
||||||
|
let core_models::Note {
|
||||||
|
id,
|
||||||
|
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<core_models::NoteLabel> for NoteLabel {
|
||||||
|
fn from(value: core_models::NoteLabel) -> Self {
|
||||||
|
let core_models::NoteLabel {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
via_tag,
|
||||||
|
} = value;
|
||||||
|
NoteLabel {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
via_tag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::ChecklistItem> for ChecklistItem {
|
||||||
|
fn from(value: core_models::ChecklistItem) -> Self {
|
||||||
|
let core_models::ChecklistItem {
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
checked,
|
||||||
|
position,
|
||||||
|
} = value;
|
||||||
|
ChecklistItem {
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
checked,
|
||||||
|
position,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::Attachment> for Attachment {
|
||||||
|
fn from(value: core_models::Attachment) -> Self {
|
||||||
|
let core_models::Attachment {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
filename,
|
||||||
|
mime,
|
||||||
|
size,
|
||||||
|
sha256,
|
||||||
|
} = value;
|
||||||
|
Attachment {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
filename,
|
||||||
|
mime,
|
||||||
|
size,
|
||||||
|
sha256,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::LinkPreview> for LinkPreview {
|
||||||
|
fn from(value: core_models::LinkPreview) -> Self {
|
||||||
|
let core_models::LinkPreview {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image_url,
|
||||||
|
site_name,
|
||||||
|
} = value;
|
||||||
|
LinkPreview {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image_url,
|
||||||
|
site_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── queries and edits ─────────────────────────────
|
||||||
|
|
||||||
|
/// What the board is asking for. Mirrors the core's `ListQuery`.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteQuery {
|
||||||
|
/// "notes" | "archive" | "trash" | "reminders" | "labels" — the core validates.
|
||||||
|
pub view: String,
|
||||||
|
pub label_id: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub facets: Option<NoteFacets>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteFacets {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub label: Option<Vec<String>>,
|
||||||
|
pub has_reminder: Option<bool>,
|
||||||
|
pub has_attachment: Option<bool>,
|
||||||
|
pub created_after: Option<String>,
|
||||||
|
pub created_before: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NoteQuery> for core_models::ListQuery {
|
||||||
|
fn from(value: NoteQuery) -> Self {
|
||||||
|
let NoteQuery {
|
||||||
|
view,
|
||||||
|
label_id,
|
||||||
|
sort,
|
||||||
|
facets,
|
||||||
|
} = value;
|
||||||
|
core_models::ListQuery {
|
||||||
|
view,
|
||||||
|
label_id,
|
||||||
|
sort,
|
||||||
|
facets: facets.map(core_models::Facets::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NoteFacets> for core_models::Facets {
|
||||||
|
fn from(value: NoteFacets) -> Self {
|
||||||
|
let NoteFacets {
|
||||||
|
q,
|
||||||
|
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<String>,
|
||||||
|
/// Checklist lines, for `kind = "checklist"`.
|
||||||
|
pub items: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NoteDraft> 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<NoteEdit>) -> serde_json::Value {
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
for edit in edits {
|
||||||
|
let (key, value) = edit.entry();
|
||||||
|
map.insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── sync ─────────────────────────────────
|
||||||
|
|
||||||
|
/// What the UI may know about the link. Carries no device token, deliberately —
|
||||||
|
/// the core withholds it from `Status` for the same reason, and a bearer token has
|
||||||
|
/// no business in UI state.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SyncStatus {
|
||||||
|
pub linked: bool,
|
||||||
|
pub server_url: Option<String>,
|
||||||
|
pub last_cursor: i64,
|
||||||
|
pub last_sync_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_state::Status> for SyncStatus {
|
||||||
|
fn from(value: core_state::Status) -> Self {
|
||||||
|
let core_state::Status {
|
||||||
|
linked,
|
||||||
|
server_url,
|
||||||
|
last_cursor,
|
||||||
|
last_sync_at,
|
||||||
|
} = value;
|
||||||
|
SyncStatus {
|
||||||
|
linked,
|
||||||
|
server_url,
|
||||||
|
last_cursor,
|
||||||
|
last_sync_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a server said about itself, before committing to anything.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ProbeResult {
|
||||||
|
/// Normalised by the core — this, not what the user typed, is what gets stored.
|
||||||
|
pub base_url: String,
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub trash_retention_days: Option<u32>,
|
||||||
|
pub compatibility: Compatibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this client and that server can sync at all.
|
||||||
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
|
pub enum Compatibility {
|
||||||
|
Ok,
|
||||||
|
/// Safe to sync, but these named capabilities are missing. The UI should say so
|
||||||
|
/// rather than let a feature silently do nothing.
|
||||||
|
Degraded {
|
||||||
|
unavailable: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Do not sync. `client_must_update` says which side can fix it, so the message
|
||||||
|
/// can be actionable.
|
||||||
|
Incompatible {
|
||||||
|
reason: String,
|
||||||
|
client_must_update: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_compat::Compatibility> for Compatibility {
|
||||||
|
fn from(value: core_compat::Compatibility) -> Self {
|
||||||
|
match value {
|
||||||
|
core_compat::Compatibility::Ok => Compatibility::Ok,
|
||||||
|
core_compat::Compatibility::Degraded { unavailable } => {
|
||||||
|
Compatibility::Degraded { unavailable }
|
||||||
|
}
|
||||||
|
core_compat::Compatibility::Incompatible {
|
||||||
|
reason,
|
||||||
|
client_must_update,
|
||||||
|
} => Compatibility::Incompatible {
|
||||||
|
reason,
|
||||||
|
client_must_update,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::ProbeResult> for ProbeResult {
|
||||||
|
fn from(value: core_client::ProbeResult) -> Self {
|
||||||
|
let core_client::ProbeResult {
|
||||||
|
base_url,
|
||||||
|
server,
|
||||||
|
compatibility,
|
||||||
|
} = value;
|
||||||
|
let core_compat::ServerInfo {
|
||||||
|
site_name,
|
||||||
|
version,
|
||||||
|
// Protocol numbers are the raw material of the compatibility verdict,
|
||||||
|
// which is already carried above in a form the UI can act on. Sending
|
||||||
|
// them too would invite a second, worse judgement being made in Kotlin.
|
||||||
|
sync_protocol_version: _,
|
||||||
|
min_client_protocol_version: _,
|
||||||
|
sync_features: _,
|
||||||
|
trash_retention_days,
|
||||||
|
} = server;
|
||||||
|
ProbeResult {
|
||||||
|
base_url,
|
||||||
|
site_name,
|
||||||
|
version,
|
||||||
|
trash_retention_days,
|
||||||
|
compatibility: compatibility.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who the server thinks this device belongs to.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Identity {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::Identity> for Identity {
|
||||||
|
fn from(value: core_client::Identity) -> Self {
|
||||||
|
let core_client::Identity {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
} = value;
|
||||||
|
Identity {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What became of this device's token on the server during an unlink.
|
||||||
|
///
|
||||||
|
/// Separate from the local result because the local half always succeeds and the
|
||||||
|
/// remote half may not — someone unlinking a machine they are selling deserves to be
|
||||||
|
/// told plainly that the token is still live.
|
||||||
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
|
pub enum RevokeOutcome {
|
||||||
|
Revoked,
|
||||||
|
/// This server predates the self-revoke route. Only the web app can retire it.
|
||||||
|
Unsupported,
|
||||||
|
Failed {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// Nothing to revoke; the app wasn't linked.
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::RevokeOutcome> for RevokeOutcome {
|
||||||
|
fn from(value: core_client::RevokeOutcome) -> Self {
|
||||||
|
match value {
|
||||||
|
core_client::RevokeOutcome::Revoked => RevokeOutcome::Revoked,
|
||||||
|
core_client::RevokeOutcome::Unsupported => RevokeOutcome::Unsupported,
|
||||||
|
core_client::RevokeOutcome::Failed { reason } => RevokeOutcome::Failed { reason },
|
||||||
|
core_client::RevokeOutcome::Skipped => RevokeOutcome::Skipped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of one full push-then-pull cycle.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SyncOutcome {
|
||||||
|
pub push: PushSummary,
|
||||||
|
pub pull: PullSummary,
|
||||||
|
/// The state after the cycle, so the UI refreshes from one call rather than
|
||||||
|
/// following every sync with a status query.
|
||||||
|
pub status: SyncStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts are `u64` because the core uses `usize`, which has no uniffi
|
||||||
|
/// representation. Widening is lossless on every target we build for; narrowing to
|
||||||
|
/// u32 would be a silent truncation waiting for a very large sync.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct PushSummary {
|
||||||
|
pub batches: u64,
|
||||||
|
pub sent: u64,
|
||||||
|
pub created: u64,
|
||||||
|
pub applied: u64,
|
||||||
|
/// The server had a newer edit and kept it. Not a failure — the local row stops
|
||||||
|
/// being dirty and the following pull adopts the server's version.
|
||||||
|
pub kept: u64,
|
||||||
|
pub noop: u64,
|
||||||
|
/// Still dirty, and surfaced: these need a human (a duplicate label name is the
|
||||||
|
/// realistic case). Silently retrying forever would be the wrong shape.
|
||||||
|
pub rejected: u64,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct PullSummary {
|
||||||
|
pub pages: u64,
|
||||||
|
pub notes_applied: u64,
|
||||||
|
pub notes_deleted: u64,
|
||||||
|
pub labels_applied: u64,
|
||||||
|
pub labels_deleted: u64,
|
||||||
|
pub cursor: i64,
|
||||||
|
/// Rows that still held unpushed local edits when the server's version landed on
|
||||||
|
/// top. Should be 0 in a normal cycle, because push runs first; anything higher
|
||||||
|
/// means local work was overwritten, which is worth saying out loud.
|
||||||
|
pub clobbered_dirty: u64,
|
||||||
|
pub blobs_downloaded: u64,
|
||||||
|
/// Attachments whose bytes couldn't be fetched or failed verification. Counted
|
||||||
|
/// rather than fatal.
|
||||||
|
pub blobs_failed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_push::PushSummary> for PushSummary {
|
||||||
|
fn from(value: core_push::PushSummary) -> Self {
|
||||||
|
let core_push::PushSummary {
|
||||||
|
batches,
|
||||||
|
sent,
|
||||||
|
created,
|
||||||
|
applied,
|
||||||
|
kept,
|
||||||
|
noop,
|
||||||
|
rejected,
|
||||||
|
errors,
|
||||||
|
} = 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<core_pull::PullSummary> for PullSummary {
|
||||||
|
fn from(value: core_pull::PullSummary) -> Self {
|
||||||
|
let core_pull::PullSummary {
|
||||||
|
pages,
|
||||||
|
notes_applied,
|
||||||
|
notes_deleted,
|
||||||
|
labels_applied,
|
||||||
|
labels_deleted,
|
||||||
|
cursor,
|
||||||
|
clobbered_dirty,
|
||||||
|
blobs_downloaded,
|
||||||
|
blobs_failed,
|
||||||
|
} = value;
|
||||||
|
PullSummary {
|
||||||
|
pages: pages as u64,
|
||||||
|
notes_applied: notes_applied as u64,
|
||||||
|
notes_deleted: notes_deleted as u64,
|
||||||
|
labels_applied: labels_applied as u64,
|
||||||
|
labels_deleted: labels_deleted as u64,
|
||||||
|
cursor,
|
||||||
|
clobbered_dirty: clobbered_dirty as u64,
|
||||||
|
blobs_downloaded: blobs_downloaded as u64,
|
||||||
|
blobs_failed: blobs_failed as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_engine::SyncOutcome> for SyncOutcome {
|
||||||
|
fn from(value: core_engine::SyncOutcome) -> Self {
|
||||||
|
let core_engine::SyncOutcome { push, pull, status } = value;
|
||||||
|
SyncOutcome {
|
||||||
|
push: push.into(),
|
||||||
|
pull: pull.into(),
|
||||||
|
status: status.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_set_and_a_clear_are_different_patch_entries() {
|
||||||
|
let set = patch_from(vec![NoteEdit::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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -21,6 +21,26 @@ use rusqlite::Connection;
|
|||||||
/// it in the uniffi object.
|
/// it in the uniffi object.
|
||||||
pub struct Db(pub Mutex<Connection>);
|
pub struct Db(pub Mutex<Connection>);
|
||||||
|
|
||||||
|
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<std::sync::MutexGuard<'_, Connection>, 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.
|
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||||
pub fn open(path: &Path) -> rusqlite::Result<Db> {
|
pub fn open(path: &Path) -> rusqlite::Result<Db> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user