feat: improve mobile native impl (#14481)

fix #13529 

#### PR Dependency Tree


* **PR #14481** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Mobile blob caching with file-backed storage for faster loads and
reduced network usage
* Blob decoding with lazy refresh on token-read failures for improved
reliability
  * Full-text search/indexing exposed to mobile apps
* Document sync APIs and peer clock management for robust cross-device
sync

* **Tests**
* Added unit tests covering payload decoding, cache safety, and
concurrency

* **Dependencies**
* Added an LRU cache dependency and a new mobile-shared package for
shared mobile logic
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-21 04:13:24 +08:00
committed by GitHub
parent d8cc0acdd0
commit c9bffc13b5
37 changed files with 2325 additions and 866 deletions
@@ -0,0 +1,182 @@
use super::*;
#[uniffi::export(async_runtime = "tokio")]
impl DocStoragePool {
pub async fn get_blob(&self, universal_id: String, key: String) -> Result<Option<Blob>> {
#[cfg(any(target_os = "android", target_os = "ios", test))]
{
use affine_nbstore::Blob as NbBlob;
enum BlobEncodeOutcome {
Cached(Blob),
Inline(NbBlob),
}
let universal_id_for_cache = universal_id.clone();
let key_for_cache = key.clone();
if let Ok(Some(blob)) = self
.run_mobile_cache_io(
move |cache| Ok(cache.get_blob(&universal_id_for_cache, &key_for_cache)),
"Failed to read mobile blob cache",
)
.await
{
return Ok(Some(blob));
}
let Some(blob) = self
.inner
.get(universal_id.clone())
.await?
.get_blob(key.clone())
.await?
else {
return Ok(None);
};
if !should_cache_payload_as_file(blob.data.len()) {
return Ok(Some(blob.into()));
}
let universal_id_for_cache = universal_id.clone();
let key_for_fallback = key.clone();
return match self
.run_mobile_cache_io(
move |cache| {
Ok(match cache.cache_blob(&universal_id_for_cache, &blob) {
Ok(cached) => BlobEncodeOutcome::Cached(cached),
Err(_) => BlobEncodeOutcome::Inline(blob),
})
},
"Failed to cache blob file",
)
.await
{
Ok(BlobEncodeOutcome::Cached(cached)) => Ok(Some(cached)),
Ok(BlobEncodeOutcome::Inline(blob)) => Ok(Some(blob.into())),
Err(_) => Ok(
self
.inner
.get(universal_id)
.await?
.get_blob(key_for_fallback)
.await?
.map(Into::into),
),
};
}
#[cfg(not(any(target_os = "android", target_os = "ios", test)))]
{
Ok(self.inner.get(universal_id).await?.get_blob(key).await?.map(Into::into))
}
}
pub async fn set_blob(&self, universal_id: String, blob: SetBlob) -> Result<()> {
#[cfg(any(target_os = "android", target_os = "ios", test))]
let key = blob.key.clone();
let blob = NbSetBlob {
key: blob.key,
data: Into::<Data>::into(self.decode_blob_data(&universal_id, &blob.data).await?),
mime: blob.mime,
};
self.inner.get(universal_id.clone()).await?.set_blob(blob).await?;
#[cfg(any(target_os = "android", target_os = "ios", test))]
{
let universal_id_for_cache = universal_id;
let _ = self
.run_mobile_cache_io(
move |cache| {
cache.invalidate_blob(&universal_id_for_cache, &key);
Ok(())
},
"Failed to invalidate mobile blob cache entry",
)
.await;
}
Ok(())
}
pub async fn delete_blob(&self, universal_id: String, key: String, permanently: bool) -> Result<()> {
self
.inner
.get(universal_id.clone())
.await?
.delete_blob(key.clone(), permanently)
.await?;
#[cfg(any(target_os = "android", target_os = "ios", test))]
{
let universal_id_for_cache = universal_id;
let _ = self
.run_mobile_cache_io(
move |cache| {
cache.invalidate_blob(&universal_id_for_cache, &key);
Ok(())
},
"Failed to invalidate mobile blob cache entry",
)
.await;
}
Ok(())
}
pub async fn release_blobs(&self, universal_id: String) -> Result<()> {
self.inner.get(universal_id.clone()).await?.release_blobs().await?;
#[cfg(any(target_os = "android", target_os = "ios", test))]
{
let universal_id_for_cache = universal_id;
let _ = self
.run_mobile_cache_io(
move |cache| {
cache.clear_workspace_cache(&universal_id_for_cache);
Ok(())
},
"Failed to clear mobile blob cache workspace",
)
.await;
}
Ok(())
}
pub async fn list_blobs(&self, universal_id: String) -> Result<Vec<ListedBlob>> {
Ok(
self
.inner
.get(universal_id)
.await?
.list_blobs()
.await?
.into_iter()
.map(Into::into)
.collect(),
)
}
pub async fn set_blob_uploaded_at(
&self,
universal_id: String,
peer: String,
blob_id: String,
uploaded_at: Option<i64>,
) -> Result<()> {
Ok(
self
.inner
.get(universal_id)
.await?
.set_blob_uploaded_at(peer, blob_id, uploaded_at.map(millis_to_naive_utc).transpose()?)
.await?,
)
}
pub async fn get_blob_uploaded_at(&self, universal_id: String, peer: String, blob_id: String) -> Result<Option<i64>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_blob_uploaded_at(peer, blob_id)
.await?
.map(|t| t.and_utc().timestamp_millis()),
)
}
}
@@ -0,0 +1,105 @@
use super::*;
#[uniffi::export(async_runtime = "tokio")]
impl DocStoragePool {
pub async fn get_doc_snapshot(&self, universal_id: String, doc_id: String) -> Result<Option<DocRecord>> {
let Some(record) = self
.inner
.get(universal_id.clone())
.await?
.get_doc_snapshot(doc_id)
.await?
else {
return Ok(None);
};
let timestamp = record.timestamp.and_utc().timestamp_millis();
let bin = self
.encode_doc_data(&universal_id, &record.doc_id, timestamp, &record.bin)
.await?;
Ok(Some(DocRecord {
doc_id: record.doc_id,
bin,
timestamp,
}))
}
pub async fn set_doc_snapshot(&self, universal_id: String, snapshot: DocRecord) -> Result<bool> {
let doc_record = NbDocRecord {
doc_id: snapshot.doc_id,
bin: Into::<Data>::into(self.decode_base64_payload(&snapshot.bin)?),
timestamp: millis_to_naive_utc(snapshot.timestamp)?,
};
Ok(self.inner.get(universal_id).await?.set_doc_snapshot(doc_record).await?)
}
pub async fn get_doc_updates(&self, universal_id: String, doc_id: String) -> Result<Vec<DocUpdate>> {
let updates = self
.inner
.get(universal_id.clone())
.await?
.get_doc_updates(doc_id)
.await?;
let mut converted = Vec::with_capacity(updates.len());
for update in updates {
let timestamp = update.timestamp.and_utc().timestamp_millis();
let bin = self
.encode_doc_data(&universal_id, &update.doc_id, timestamp, &update.bin)
.await?;
converted.push(DocUpdate {
doc_id: update.doc_id,
timestamp,
bin,
});
}
Ok(converted)
}
pub async fn mark_updates_merged(&self, universal_id: String, doc_id: String, updates: Vec<i64>) -> Result<u32> {
Ok(
self
.inner
.get(universal_id)
.await?
.mark_updates_merged(
doc_id,
updates
.into_iter()
.map(millis_to_naive_utc)
.collect::<Result<Vec<_>>>()?,
)
.await?,
)
}
pub async fn delete_doc(&self, universal_id: String, doc_id: String) -> Result<()> {
Ok(self.inner.get(universal_id).await?.delete_doc(doc_id).await?)
}
pub async fn get_doc_clocks(&self, universal_id: String, after: Option<i64>) -> Result<Vec<DocClock>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_doc_clocks(after.map(millis_to_naive_utc).transpose()?)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
}
pub async fn get_doc_clock(&self, universal_id: String, doc_id: String) -> Result<Option<DocClock>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_doc_clock(doc_id)
.await?
.map(Into::into),
)
}
}
@@ -0,0 +1,100 @@
use super::*;
#[uniffi::export(async_runtime = "tokio")]
impl DocStoragePool {
pub async fn crawl_doc_data(&self, universal_id: String, doc_id: String) -> Result<CrawlResult> {
let result = self
.inner
.get(universal_id.clone())
.await?
.crawl_doc_data(&doc_id)
.await?;
Ok(result.into())
}
pub async fn fts_add_document(
&self,
universal_id: String,
index_name: String,
doc_id: String,
text: String,
index: bool,
) -> Result<()> {
self
.inner
.get(universal_id)
.await?
.fts_add(&index_name, &doc_id, &text, index)
.await?;
Ok(())
}
pub async fn fts_delete_document(&self, universal_id: String, index_name: String, doc_id: String) -> Result<()> {
self
.inner
.get(universal_id)
.await?
.fts_delete(&index_name, &doc_id)
.await?;
Ok(())
}
pub async fn fts_get_document(
&self,
universal_id: String,
index_name: String,
doc_id: String,
) -> Result<Option<String>> {
Ok(
self
.inner
.get(universal_id)
.await?
.fts_get(&index_name, &doc_id)
.await?,
)
}
pub async fn fts_search(&self, universal_id: String, index_name: String, query: String) -> Result<Vec<SearchHit>> {
Ok(
self
.inner
.get(universal_id)
.await?
.fts_search(&index_name, &query)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
}
pub async fn fts_get_matches(
&self,
universal_id: String,
index_name: String,
doc_id: String,
query: String,
) -> Result<Vec<MatchRange>> {
Ok(
self
.inner
.get(universal_id)
.await?
.fts_get_matches(&index_name, &doc_id, &query)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
}
pub async fn fts_flush_index(&self, universal_id: String) -> Result<()> {
self.inner.get(universal_id).await?.flush_index().await?;
Ok(())
}
pub async fn fts_index_version(&self) -> Result<u32> {
Ok(SqliteDocStorage::index_version())
}
}
@@ -0,0 +1,74 @@
use super::*;
#[uniffi::export(async_runtime = "tokio")]
impl DocStoragePool {
/// Initialize the database and run migrations.
pub async fn connect(&self, universal_id: String, path: String) -> Result<()> {
#[cfg(any(target_os = "android", target_os = "ios", test))]
{
let universal_id_for_cache = universal_id.clone();
let path_for_cache = path.clone();
self
.run_mobile_cache_io(
move |cache| cache.register_workspace(&universal_id_for_cache, &path_for_cache),
"Failed to initialize mobile blob cache",
)
.await?;
}
if let Err(err) = self.inner.connect(universal_id.clone(), path).await {
#[cfg(any(target_os = "android", target_os = "ios", test))]
{
let universal_id_for_cache = universal_id.clone();
let _ = self
.run_mobile_cache_io(
move |cache| {
cache.invalidate_workspace(&universal_id_for_cache);
Ok(())
},
"Failed to rollback mobile blob cache workspace",
)
.await;
}
return Err(err.into());
}
Ok(())
}
pub async fn disconnect(&self, universal_id: String) -> Result<()> {
#[cfg(any(target_os = "android", target_os = "ios", test))]
{
let universal_id_for_cache = universal_id.clone();
let _ = self
.run_mobile_cache_io(
move |cache| {
cache.invalidate_workspace(&universal_id_for_cache);
Ok(())
},
"Failed to clear mobile blob cache workspace",
)
.await;
}
self.inner.disconnect(universal_id).await?;
Ok(())
}
pub async fn set_space_id(&self, universal_id: String, space_id: String) -> Result<()> {
Ok(self.inner.get(universal_id).await?.set_space_id(space_id).await?)
}
pub async fn push_update(&self, universal_id: String, doc_id: String, update: String) -> Result<i64> {
let decoded_update = self.decode_base64_payload(&update)?;
Ok(
self
.inner
.get(universal_id)
.await?
.push_update(doc_id, decoded_update)
.await?
.and_utc()
.timestamp_millis(),
)
}
}
@@ -0,0 +1,90 @@
mod blobs;
mod docs;
mod indexer;
mod lifecycle;
mod peers;
#[cfg(any(target_os = "android", target_os = "ios", test))]
use std::sync::Arc;
use affine_nbstore::{
Data, DocRecord as NbDocRecord, SetBlob as NbSetBlob, pool::SqliteDocStoragePool, storage::SqliteDocStorage,
};
use chrono::{DateTime, NaiveDateTime, Utc};
#[cfg(any(target_os = "android", target_os = "ios", test))]
use crate::cache::{MobileBlobCache, is_mobile_binary_file_token, should_cache_payload_as_file};
use crate::{
Blob, CrawlResult, DocClock, DocRecord, DocUpdate, ListedBlob, MatchRange, Result, SearchHit, SetBlob, UniffiError,
payload_codec::{decode_base64_data, encode_base64_data},
};
fn millis_to_naive_utc(millis: i64) -> Result<NaiveDateTime> {
DateTime::<Utc>::from_timestamp_millis(millis)
.ok_or(UniffiError::TimestampDecodingError)
.map(|value| value.naive_utc())
}
#[derive(uniffi::Object)]
pub struct DocStoragePool {
inner: SqliteDocStoragePool,
#[cfg(any(target_os = "android", target_os = "ios", test))]
mobile_blob_cache: Arc<MobileBlobCache>,
}
#[uniffi::export]
pub fn new_doc_storage_pool() -> DocStoragePool {
DocStoragePool {
inner: Default::default(),
#[cfg(any(target_os = "android", target_os = "ios", test))]
mobile_blob_cache: Arc::new(MobileBlobCache::new()),
}
}
impl DocStoragePool {
#[cfg(any(target_os = "android", target_os = "ios", test))]
async fn run_mobile_cache_io<T, F>(&self, task: F, context: &'static str) -> Result<T>
where
T: Send + 'static,
F: FnOnce(Arc<MobileBlobCache>) -> std::io::Result<T> + Send + 'static,
{
let cache = Arc::clone(&self.mobile_blob_cache);
tokio::task::spawn_blocking(move || task(cache))
.await
.map_err(|err| UniffiError::Err(format!("{context}: {err}")))?
.map_err(|err| UniffiError::Err(format!("{context}: {err}")))
}
pub(crate) fn decode_base64_payload(&self, data: &str) -> Result<Vec<u8>> {
decode_base64_data(data)
}
pub(crate) async fn decode_blob_data(&self, universal_id: &str, data: &str) -> Result<Vec<u8>> {
#[cfg(any(target_os = "android", target_os = "ios", test))]
if is_mobile_binary_file_token(data) {
let universal_id = universal_id.to_string();
let data = data.to_string();
return self
.run_mobile_cache_io(
move |cache| cache.read_binary_file(&universal_id, &data),
"Failed to read mobile file token",
)
.await;
}
#[cfg(not(any(target_os = "android", target_os = "ios", test)))]
let _ = universal_id;
self.decode_base64_payload(data)
}
pub(crate) async fn encode_doc_data(
&self,
universal_id: &str,
doc_id: &str,
timestamp: i64,
data: &[u8],
) -> Result<String> {
let _ = (universal_id, doc_id, timestamp);
Ok(encode_base64_data(data))
}
}
@@ -0,0 +1,152 @@
use super::*;
#[uniffi::export(async_runtime = "tokio")]
impl DocStoragePool {
pub async fn get_peer_remote_clocks(&self, universal_id: String, peer: String) -> Result<Vec<DocClock>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_peer_remote_clocks(peer)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
}
pub async fn get_peer_remote_clock(
&self,
universal_id: String,
peer: String,
doc_id: String,
) -> Result<Option<DocClock>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_peer_remote_clock(peer, doc_id)
.await?
.map(Into::into),
)
}
pub async fn set_peer_remote_clock(
&self,
universal_id: String,
peer: String,
doc_id: String,
clock: i64,
) -> Result<()> {
Ok(
self
.inner
.get(universal_id)
.await?
.set_peer_remote_clock(peer, doc_id, millis_to_naive_utc(clock)?)
.await?,
)
}
pub async fn get_peer_pulled_remote_clocks(&self, universal_id: String, peer: String) -> Result<Vec<DocClock>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_peer_pulled_remote_clocks(peer)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
}
pub async fn get_peer_pulled_remote_clock(
&self,
universal_id: String,
peer: String,
doc_id: String,
) -> Result<Option<DocClock>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_peer_pulled_remote_clock(peer, doc_id)
.await?
.map(Into::into),
)
}
pub async fn set_peer_pulled_remote_clock(
&self,
universal_id: String,
peer: String,
doc_id: String,
clock: i64,
) -> Result<()> {
Ok(
self
.inner
.get(universal_id)
.await?
.set_peer_pulled_remote_clock(peer, doc_id, millis_to_naive_utc(clock)?)
.await?,
)
}
pub async fn get_peer_pushed_clock(
&self,
universal_id: String,
peer: String,
doc_id: String,
) -> Result<Option<DocClock>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_peer_pushed_clock(peer, doc_id)
.await?
.map(Into::into),
)
}
pub async fn get_peer_pushed_clocks(&self, universal_id: String, peer: String) -> Result<Vec<DocClock>> {
Ok(
self
.inner
.get(universal_id)
.await?
.get_peer_pushed_clocks(peer)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
}
pub async fn set_peer_pushed_clock(
&self,
universal_id: String,
peer: String,
doc_id: String,
clock: i64,
) -> Result<()> {
Ok(
self
.inner
.get(universal_id)
.await?
.set_peer_pushed_clock(peer, doc_id, millis_to_naive_utc(clock)?)
.await?,
)
}
pub async fn clear_clocks(&self, universal_id: String) -> Result<()> {
Ok(self.inner.get(universal_id).await?.clear_clocks().await?)
}
}