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

View File

@@ -167,14 +167,23 @@ afterEvaluate {
}
def buildType = "${variant.buildType.name.capitalize()}"
tasks["generate${productFlavor}${buildType}Assets"].dependsOn(tasks["cargoBuild"])
def variantName = "${variant.name.substring(0,1).toUpperCase()}${variant.name.substring(1)}"
def mergeNativeLibsTask = tasks.findByName("merge${variantName}NativeLibs")
if (mergeNativeLibsTask != null) {
mergeNativeLibsTask.dependsOn(tasks["cargoBuild"])
}
}
}
def localProps = new Properties()
file("${rootProject.projectDir}/local.properties").withInputStream { localProps.load(it) }
def cargoPath = localProps.getProperty("rust.cargoCommand") ?: 'cargo'
android.applicationVariants.configureEach { variant ->
def t = tasks.register("generate${variant.name.capitalize()}UniFFIBindings", Exec) {
def variantName = "${variant.name.substring(0,1).toUpperCase()}${variant.name.substring(1)}"
def t = tasks.register("generate${variantName}UniFFIBindings", Exec) {
workingDir "${project.projectDir}"
// Runs the bindings generation, note that you must have uniffi-bindgen installed and in your PATH environment variable
commandLine 'cargo', 'run', '--bin', 'uniffi-bindgen', 'generate', '--library', "${buildDir}/rustJniLibs/android/arm64-v8a/libaffine_mobile_native.so", '--language', 'kotlin', '--out-dir', "${project.projectDir}/src/main/java"
commandLine "${cargoPath}", 'run', '--bin', 'uniffi-bindgen', 'generate', '--library', "${buildDir}/rustJniLibs/android/arm64-v8a/libaffine_mobile_native.so", '--language', 'kotlin', '--out-dir', "${project.projectDir}/src/main/java"
dependsOn("cargoBuild")
}
variant.javaCompileProvider.get().dependsOn(t)

View File

@@ -15,6 +15,7 @@
"@affine/core": "workspace:*",
"@affine/env": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/mobile-shared": "workspace:*",
"@affine/nbstore": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",

View File

@@ -2,7 +2,7 @@ import type { CrawlResult, DocIndexedClock } from '@affine/nbstore';
export interface Blob {
key: string;
// base64 encoded data
// base64 encoded data, or "__AFFINE_BLOB_FILE__:<absolutePath>" for large blobs
data: string;
mime: string;
size: number;
@@ -41,6 +41,7 @@ export interface NbStorePlugin {
pushUpdate: (options: {
id: string;
docId: string;
// base64 encoded data
data: string;
}) => Promise<{ timestamp: number }>;
getDocSnapshot: (options: { id: string; docId: string }) => Promise<
@@ -55,6 +56,7 @@ export interface NbStorePlugin {
setDocSnapshot: (options: {
id: string;
docId: string;
// base64 encoded data
bin: string;
timestamp: number;
}) => Promise<{ success: boolean }>;

View File

@@ -2,6 +2,10 @@ import {
base64ToUint8Array,
uint8ArrayToBase64,
} from '@affine/core/modules/workspace-engine';
import {
decodePayload,
MOBILE_BLOB_FILE_PREFIX,
} from '@affine/mobile-shared/nbstore/payload';
import {
type BlobRecord,
type CrawlResult,
@@ -132,14 +136,23 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id,
key,
});
return record
? {
data: base64ToUint8Array(record.data),
key: record.key,
mime: record.mime,
createdAt: new Date(record.createdAt),
}
: null;
if (!record) {
return null;
}
let refreshedBlobPromise: ReturnType<typeof NbStore.getBlob> | undefined;
return {
data: await decodePayload(record.data, MOBILE_BLOB_FILE_PREFIX, {
onTokenReadFailure: async () => {
refreshedBlobPromise ??= NbStore.getBlob({ id, key });
return (await refreshedBlobPromise)?.data;
},
}),
key: record.key,
mime: record.mime,
createdAt: new Date(record.createdAt),
};
},
setBlob: async function (id: string, blob: BlobRecord): Promise<void> {
await NbStore.setBlob({

View File

@@ -11,6 +11,7 @@
{ "path": "../../core" },
{ "path": "../../../common/env" },
{ "path": "../../i18n" },
{ "path": "../mobile-shared" },
{ "path": "../../../common/nbstore" },
{ "path": "../../track" },
{ "path": "../../../../blocksuite/affine/all" },

View File

@@ -19,6 +19,7 @@
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/mobile-shared": "workspace:*",
"@affine/nbstore": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",

View File

@@ -2,7 +2,7 @@ import type { CrawlResult, DocIndexedClock } from '@affine/nbstore';
export interface Blob {
key: string;
// base64 encoded data
// base64 encoded data, or "__AFFINE_BLOB_FILE__:<absolutePath>" for large blobs
data: string;
mime: string;
size: number;
@@ -41,6 +41,7 @@ export interface NbStorePlugin {
pushUpdate: (options: {
id: string;
docId: string;
// base64 encoded data
data: string;
}) => Promise<{ timestamp: number }>;
getDocSnapshot: (options: { id: string; docId: string }) => Promise<
@@ -55,6 +56,7 @@ export interface NbStorePlugin {
setDocSnapshot: (options: {
id: string;
docId: string;
// base64 encoded data
bin: string;
timestamp: number;
}) => Promise<{ success: boolean }>;

View File

@@ -2,6 +2,10 @@ import {
base64ToUint8Array,
uint8ArrayToBase64,
} from '@affine/core/modules/workspace-engine';
import {
decodePayload,
MOBILE_BLOB_FILE_PREFIX,
} from '@affine/mobile-shared/nbstore/payload';
import {
type BlobRecord,
type CrawlResult,
@@ -132,14 +136,23 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id,
key,
});
return record
? {
data: base64ToUint8Array(record.data),
key: record.key,
mime: record.mime,
createdAt: new Date(record.createdAt),
}
: null;
if (!record) {
return null;
}
let refreshedBlobPromise: ReturnType<typeof NbStore.getBlob> | undefined;
return {
data: await decodePayload(record.data, MOBILE_BLOB_FILE_PREFIX, {
onTokenReadFailure: async () => {
refreshedBlobPromise ??= NbStore.getBlob({ id, key });
return (await refreshedBlobPromise)?.data;
},
}),
key: record.key,
mime: record.mime,
createdAt: new Date(record.createdAt),
};
},
setBlob: async function (id: string, blob: BlobRecord): Promise<void> {
await NbStore.setBlob({

View File

@@ -12,6 +12,7 @@
{ "path": "../../../common/env" },
{ "path": "../../../common/graphql" },
{ "path": "../../i18n" },
{ "path": "../mobile-shared" },
{ "path": "../../../common/nbstore" },
{ "path": "../../track" },
{ "path": "../../../../blocksuite/affine/all" },

View File

@@ -0,0 +1,19 @@
{
"name": "@affine/mobile-shared",
"version": "0.26.1",
"type": "module",
"private": true,
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./nbstore/payload": "./src/nbstore/payload.ts"
},
"dependencies": {
"@affine/core": "workspace:*",
"@capacitor/core": "^7.0.0"
},
"devDependencies": {
"typescript": "^5.7.2",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1 @@
export * from './nbstore/payload';

View File

@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { mockBase64ToUint8Array, mockConvertFileSrc } = vi.hoisted(() => ({
mockBase64ToUint8Array: vi.fn((data: string) =>
Uint8Array.from(data.split('').map(char => char.charCodeAt(0)))
),
mockConvertFileSrc: vi.fn((path: string) => `capacitor://localhost${path}`),
}));
vi.mock('@affine/core/modules/workspace-engine', () => ({
base64ToUint8Array: mockBase64ToUint8Array,
}));
vi.mock('@capacitor/core', () => ({
Capacitor: {
convertFileSrc: mockConvertFileSrc,
},
}));
import { decodePayload, MOBILE_BLOB_FILE_PREFIX } from './payload';
describe('decodePayload', () => {
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
fetchMock.mockReset();
mockBase64ToUint8Array.mockClear();
mockConvertFileSrc.mockClear();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('decodes inline base64 payloads without file IO', async () => {
const decoded = await decodePayload('ZGF0YQ==', MOBILE_BLOB_FILE_PREFIX);
expect(decoded).toEqual(Uint8Array.from([90, 71, 70, 48, 89, 81, 61, 61]));
expect(mockBase64ToUint8Array).toHaveBeenCalledWith('ZGF0YQ==');
expect(fetchMock).not.toHaveBeenCalled();
});
it('reads valid cache file tokens', async () => {
const expected = Uint8Array.from([1, 2, 3, 4]);
fetchMock.mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: async () => expected.buffer,
} as Response);
const path =
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
const decoded = await decodePayload(
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
MOBILE_BLOB_FILE_PREFIX
);
expect(decoded).toEqual(expected);
expect(mockConvertFileSrc).toHaveBeenCalledWith(`file://${path}`);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('reads valid android cache file tokens', async () => {
const expected = Uint8Array.from([4, 3, 2, 1]);
fetchMock.mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: async () => expected.buffer,
} as Response);
const path =
'/data/user/0/com.affine.app/cache/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
const decoded = await decodePayload(
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
MOBILE_BLOB_FILE_PREFIX
);
expect(decoded).toEqual(expected);
expect(mockConvertFileSrc).toHaveBeenCalledWith(`file://${path}`);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('rejects suffix-only paths outside expected cache shape', async () => {
const path =
'/attacker/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
await expect(
decodePayload(
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
MOBILE_BLOB_FILE_PREFIX
)
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects random cache roots', async () => {
const path =
'/random/cache/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
await expect(
decodePayload(
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
MOBILE_BLOB_FILE_PREFIX
)
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects encoded traversal segments', async () => {
const path =
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/%2E%2E/fedcba9876543210.blob';
await expect(
decodePayload(
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
MOBILE_BLOB_FILE_PREFIX
)
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
expect(fetchMock).not.toHaveBeenCalled();
});
it('retries once with refreshed payload when token read fails', async () => {
const path =
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
const payload = `${MOBILE_BLOB_FILE_PREFIX}${path}`;
const expected = Uint8Array.from([9, 8, 7]);
fetchMock
.mockResolvedValueOnce({
ok: false,
status: 404,
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
arrayBuffer: async () => expected.buffer,
} as Response);
const reloadedPayload = vi.fn(async () => payload);
const decoded = await decodePayload(payload, MOBILE_BLOB_FILE_PREFIX, {
onTokenReadFailure: reloadedPayload,
});
expect(decoded).toEqual(expected);
expect(reloadedPayload).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,167 @@
import { base64ToUint8Array } from '@affine/core/modules/workspace-engine';
import { Capacitor } from '@capacitor/core';
export const MOBILE_BLOB_FILE_PREFIX = '__AFFINE_BLOB_FILE__:';
export const MOBILE_PAYLOAD_INLINE_THRESHOLD_BYTES = 1024 * 1024;
const MOBILE_PAYLOAD_CACHE_DIR = 'nbstore-blob-cache';
const MOBILE_PAYLOAD_BUCKET_PATTERN = /^[0-9a-f]{16}$/;
const MOBILE_PAYLOAD_FILE_PATTERN = /^[0-9a-f]{16}\.blob$/;
const MOBILE_PAYLOAD_PARENT_DIRS = new Set(['cache', 'Caches', 'T', 'tmp']);
const MOBILE_ANDROID_PACKAGE_PATTERN =
/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
function normalizeTokenFilePath(rawPath: string): string {
const trimmedPath = rawPath.trim();
if (!trimmedPath) {
throw new Error('Invalid mobile payload token: empty file path');
}
return trimmedPath.startsWith('file://')
? trimmedPath
: `file://${trimmedPath}`;
}
function assertMobileCachePath(fileUrl: string): void {
let pathname: string;
try {
const parsedUrl = new URL(fileUrl);
if (parsedUrl.protocol !== 'file:') {
throw new Error('unexpected protocol');
}
pathname = parsedUrl.pathname;
} catch {
throw new Error('Invalid mobile payload token: malformed file URL');
}
let decodedSegments: string[];
try {
decodedSegments = pathname
.split('/')
.filter(Boolean)
.map(segment => {
const decoded = decodeURIComponent(segment);
if (
!decoded ||
decoded === '.' ||
decoded === '..' ||
decoded.includes('/') ||
decoded.includes('\\')
) {
throw new Error('path traversal');
}
return decoded;
});
} catch {
throw new Error(
`Refusing to read mobile payload outside cache dir: ${fileUrl}`
);
}
const fileName = decodedSegments.at(-1);
const bucket = decodedSegments.at(-2);
const cacheDir = decodedSegments.at(-3);
const parentDir = decodedSegments.at(-4);
const cacheParent = decodedSegments.at(-5);
if (
!fileName ||
!bucket ||
!cacheDir ||
!parentDir ||
cacheDir !== MOBILE_PAYLOAD_CACHE_DIR ||
!MOBILE_PAYLOAD_BUCKET_PATTERN.test(bucket) ||
!MOBILE_PAYLOAD_FILE_PATTERN.test(fileName) ||
!MOBILE_PAYLOAD_PARENT_DIRS.has(parentDir) ||
!isAllowedCacheParent(decodedSegments, parentDir, cacheParent)
) {
throw new Error(
`Refusing to read mobile payload outside cache dir: ${fileUrl}`
);
}
}
function isAllowedCacheParent(
parts: string[],
parentDir: string,
cacheParent: string | undefined
): boolean {
if (parentDir === 'Caches') {
return cacheParent === 'Library' && ['var', 'private'].includes(parts[0]);
}
if (parentDir === 'cache') {
if (parts[0] !== 'data' || !cacheParent) {
return false;
}
if (!MOBILE_ANDROID_PACKAGE_PATTERN.test(cacheParent)) {
return false;
}
if (parts[1] === 'data') {
return true;
}
if (parts[1] === 'user') {
return !!parts[2] && /^[0-9]+$/.test(parts[2]);
}
return false;
}
if (parentDir === 'tmp') {
return (
parts[0] === 'tmp' ||
(parts[0] === 'private' && parts[1] === 'var' && parts[2] === 'tmp')
);
}
if (parentDir === 'T') {
return parts[0] === 'var' && parts[1] === 'folders';
}
return false;
}
async function readTokenPayload(filePath: string): Promise<Uint8Array> {
const response = await fetch(Capacitor.convertFileSrc(filePath));
if (!response.ok) {
throw new Error(
`Failed to read mobile payload file: ${filePath} (status ${response.status})`
);
}
return new Uint8Array(await response.arrayBuffer());
}
export interface DecodePayloadOptions {
onTokenReadFailure?: (error: Error) => Promise<string | null | undefined>;
}
export async function decodePayload(
data: string,
prefix: string,
options?: DecodePayloadOptions
): Promise<Uint8Array> {
if (!data.startsWith(prefix)) {
return base64ToUint8Array(data);
}
const normalizedPath = normalizeTokenFilePath(data.slice(prefix.length));
assertMobileCachePath(normalizedPath);
try {
return await readTokenPayload(normalizedPath);
} catch (error) {
const reloadPayload = options?.onTokenReadFailure;
if (!reloadPayload) {
throw error;
}
const refreshedPayload = await reloadPayload(
error instanceof Error ? error : new Error(String(error))
);
if (!refreshedPayload) {
throw error;
}
return decodePayload(refreshedPayload, prefix);
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.web.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [{ "path": "../../core" }]
}