Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ jobs:
- name: Run selection history regression test
run: npm run test:native:selection-smoke

- name: Run drawing snapshot regression test
run: npm run test:native:snapshot-smoke

- name: Run zoom surface regression test
run: npm run test:native:zoom-smoke

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to `@mathnotes/mobile-ink` will be documented here.

## [0.3.9] - 2026-10-13

- Saving no longer blocks pen input. iOS and Android copy the strokes on the drawing thread and serialize, base64-encode, and wrap the copy on a background thread. Previously the whole document was serialized on the UI (iOS main / Android GL) thread on every autosave, delaying strokes drawn during a save on dense notebooks.
- Added `captureDocument` (and `NativeInkCanvasRef.captureDocument`): continuous canvases capture page payloads natively by partitioning the stroke copy off the UI thread. `ContinuousDocumentCanvas` uses it, so autosave no longer serializes the whole document, passes it through JavaScript and decodes it again (about 68% less background save CPU on a dense 100-page notebook, simulator).
- Superseded saves reject with `DocumentChangedDuringCaptureError`; `isDocumentCaptureSuperseded` lets hosts treat them as retries rather than failures.
- Continuous-document pan and zoom no longer re-render native ink every frame. `resolveDocumentRenderTarget` renders an overscanned crop that is reused until the visible area leaves it, holds the render resolution during a gesture, and settles at the nearest crisp resolution. `ContinuousDocumentCanvas` uses it and no longer re-renders its subtree on each viewport update.
- Added `npm run test:native:snapshot-smoke`, run in CI.

## [0.3.8] - 2026-10-12

- Added continuous document drawing across page boundaries, preserving stroke order, eraser targets, undo/redo, and page-local save/export data.
Expand Down
13 changes: 13 additions & 0 deletions android/src/main/cpp/document_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ extern "C" JNIEXPORT jobjectArray JNICALL Java_com_mathnotes_mobileink_DocumentC
} catch (const std::exception& error) { reject(env, error); return nullptr; }
catch (...) { reject(env, std::runtime_error("Unknown document codec failure")); return nullptr; }
}
// `snapshot` is a std::vector<Stroke>* from MobileInkCanvasView.takeDrawingSnapshot;
// the caller keeps ownership. Partitioning the copy avoids re-decoding a
// serialized document on every autosave.
extern "C" JNIEXPORT jobjectArray JNICALL Java_com_mathnotes_mobileink_DocumentCodec_partitionSnapshot(
JNIEnv* env, jobject, jlong snapshot, jint count, jfloat height) {
try {
if (!snapshot) throw std::invalid_argument("Missing drawing snapshot");
if (count < 1 || count > 100000) throw std::invalid_argument("Invalid document page count");
const auto& strokes = *reinterpret_cast<const std::vector<Stroke>*>(snapshot);
return encodePages(env, partitionContinuousDocument(strokes, count, height));
} catch (const std::exception& error) { reject(env, error); return nullptr; }
catch (...) { reject(env, std::runtime_error("Unknown document codec failure")); return nullptr; }
}
extern "C" JNIEXPORT jobjectArray JNICALL Java_com_mathnotes_mobileink_DocumentCodec_project(
JNIEnv* env, jobject, jobjectArray data, jobjectArray orders, jbooleanArray flags, jfloat height, jintArray requested) {
try {
Expand Down
43 changes: 43 additions & 0 deletions android/src/main/cpp/jni_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include <src/gpu/ganesh/gl/GrGLDefines.h>
#include <algorithm>
#include <cmath>
#include <limits>
#include <memory>
#include <string>
#include <unordered_map>
Expand Down Expand Up @@ -808,6 +809,7 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_isEmpty(JNIEnv* env, jobject ob
}

// Serialization
// Synchronous GL-thread serialization, used to preserve ink across engine resizes.
JNIEXPORT jbyteArray JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_serializeDrawing(JNIEnv* env, jobject obj, jlong contextPtr) {
auto* ctx = reinterpret_cast<DrawingContext*>(contextPtr);
Expand All @@ -821,6 +823,47 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_serializeDrawing(JNIEnv* env, j
return nullptr;
}

// Autosave copies strokes on the GL thread and encodes the copy elsewhere, so
// neither the UI nor the GL thread waits for serialization.
JNIEXPORT jlong JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_snapshotDrawing(JNIEnv* env, jobject obj, jlong contextPtr) {
auto* ctx = reinterpret_cast<DrawingContext*>(contextPtr);
if (!ctx || !ctx->engine) return 0;
try {
return reinterpret_cast<jlong>(new std::vector<nativedrawing::Stroke>(ctx->engine->snapshotDrawing()));
} catch (...) {
LOGE("snapshotDrawing: copy failed");
return 0;
}
}

JNIEXPORT jbyteArray JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_serializeDrawingSnapshot(JNIEnv* env, jobject obj, jlong snapshotPtr) {
auto* strokes = reinterpret_cast<std::vector<nativedrawing::Stroke>*>(snapshotPtr);
if (!strokes) return nullptr;
try {
nativedrawing::DrawingSerialization serializer;
std::vector<uint8_t> data = serializer.serialize(*strokes);
if (data.size() > static_cast<size_t>(std::numeric_limits<jsize>::max())) {
LOGE("serializeDrawingSnapshot: payload exceeds JNI array limit");
return nullptr;
}
jbyteArray result = env->NewByteArray(static_cast<jsize>(data.size()));
if (!result || env->ExceptionCheck()) return nullptr;
env->SetByteArrayRegion(result, 0, static_cast<jsize>(data.size()), reinterpret_cast<const jbyte*>(data.data()));
if (env->ExceptionCheck()) return nullptr;
return result;
} catch (...) {
LOGE("serializeDrawingSnapshot: encoding failed");
return nullptr;
}
}

JNIEXPORT void JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_freeDrawingSnapshot(JNIEnv* env, jobject obj, jlong snapshotPtr) {
delete reinterpret_cast<std::vector<nativedrawing::Stroke>*>(snapshotPtr);
}

JNIEXPORT void JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_deserializeDrawing(
JNIEnv* env, jobject obj, jlong contextPtr, jbyteArray data) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1334,10 +1334,39 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView.
return result
}

/**
* Copies the committed strokes on the GL thread and returns an owned
* native snapshot (0 if unavailable). The caller must pass a nonzero
* handle to [releaseDrawingSnapshot]. Encoding the copy off the UI and GL
* threads keeps autosave from blocking touch input and rendering.
*/
fun takeDrawingSnapshot(): Long {
if (drawingEngine == 0L) return 0L
// 0 = pending, CLAIMED = the caller stopped waiting. Whichever side
// loses the race frees the snapshot, so a timeout cannot leak it.
val slot = java.util.concurrent.atomic.AtomicLong(0L)
runOnGlThreadSync<Unit>(3000) {
if (drawingEngine != 0L) {
val handle = snapshotDrawing(drawingEngine)
if (handle != 0L && !slot.compareAndSet(0L, handle)) freeDrawingSnapshot(handle)
}
Unit
}
return slot.getAndSet(SNAPSHOT_CLAIMED)
}

fun releaseDrawingSnapshot(snapshot: Long) {
if (snapshot != 0L) freeDrawingSnapshot(snapshot)
}

/** Serializes a snapshot on the calling thread; call off the UI thread. */
fun serializeDrawing(): ByteArray? {
if (drawingEngine == 0L) return null
return runOnGlThreadSync(3000) {
if (drawingEngine != 0L) serializeDrawing(drawingEngine) else null
val handle = takeDrawingSnapshot()
if (handle == 0L) return null
return try {
serializeDrawingSnapshot(handle)
} finally {
releaseDrawingSnapshot(handle)
}
}

Expand Down Expand Up @@ -1467,6 +1496,9 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView.

// Serialization
private external fun serializeDrawing(engine: Long): ByteArray?
private external fun snapshotDrawing(engine: Long): Long
private external fun serializeDrawingSnapshot(snapshot: Long): ByteArray?
private external fun freeDrawingSnapshot(snapshot: Long)
private external fun deserializeDrawing(engine: Long, data: ByteArray)

// Rendering
Expand All @@ -1475,6 +1507,7 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView.
private external fun renderToPixelsScaled(engine: Long, bitmap: Bitmap, scale: Float)

companion object {
private const val SNAPSHOT_CLAIMED = -1L
private var libraryLoaded = false

@Synchronized
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,34 @@ class MobileInkDocumentBridge(context: ReactApplicationContext) : ReactContextBa
}
}

/**
* Captures the live document as page payloads. Strokes are copied on the
* view's GL thread; partitioning and encoding run on the codec worker, so
* neither touch input nor rendering waits for a save.
*/
@ReactMethod
fun captureDocument(viewTag: Int, pageCount: Double, pageHeight: Double, promise: Promise) {
if (!(pageCount.isFinite() && pageCount >= 1 && pageCount <= 100000 && pageCount == kotlin.math.floor(pageCount))) {
promise.reject("DOCUMENT_CAPTURE_FAILED", "Invalid document page count"); return
}
reactApplicationContext.runOnUiQueueThread {
val view = MobileInkModule.getView(viewTag)
if (view == null) {
promise.reject("DOCUMENT_CAPTURE_FAILED", "View not found"); return@runOnUiQueueThread
}
run(promise, "DOCUMENT_CAPTURE_FAILED") {
val snapshot = view.takeDrawingSnapshot()
require(snapshot != 0L) { "Unable to snapshot drawing" }
try {
val pages = DocumentCodec.partitionSnapshot(snapshot, pageCount.toInt(), pageHeight.toFloat())
Arguments.createArray().apply { pages.forEach { pushString(encode(it, true)) } }
} finally {
view.releaseDrawingSnapshot(snapshot)
}
}
}
}

@ReactMethod
fun projectDocumentPages(values: ReadableArray, pageHeight: Double, requested: ReadableArray, promise: Promise) {
val payloads: List<String>
Expand Down Expand Up @@ -151,5 +179,6 @@ internal object DocumentCodec {
init { System.loadLibrary("mobileink") }
external fun compose(bytes: Array<ByteArray>, orders: Array<LongArray>, hasOrders: BooleanArray, height: Float): ByteArray
external fun partition(bytes: ByteArray, order: LongArray, hasOrder: Boolean, count: Int, height: Float): Array<DocumentPageData>
external fun partitionSnapshot(snapshot: Long, count: Int, height: Float): Array<DocumentPageData>
external fun project(bytes: Array<ByteArray>, orders: Array<LongArray>, hasOrders: BooleanArray, height: Float, indices: IntArray): Array<DocumentPageData>
}
43 changes: 22 additions & 21 deletions android/src/main/java/com/mathnotes/mobileink/MobileInkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,16 @@ class MobileInkModule(private val reactContext: ReactApplicationContext) :
@ReactMethod
fun getBase64Data(viewTag: Int, promise: Promise) {
reactContext.runOnUiQueueThread {
try {
val view = findDrawingView(viewTag)
if (view != null) {
val view = findDrawingView(viewTag)
if (view == null) {
// View not found - likely unmounted during auto-save, return null gracefully
android.util.Log.w("MobileInkModule", "getBase64Data: view not found for tag $viewTag")
promise.resolve(null)
return@runOnUiQueueThread
}
// Serialization runs off the UI thread so autosave never stalls pen input.
executor.execute {
try {
val data = view.serializeDrawing()
if (data != null && data.isNotEmpty()) {
val base64 = Base64.encodeToString(data, Base64.NO_WRAP)
Expand All @@ -127,15 +134,11 @@ class MobileInkModule(private val reactContext: ReactApplicationContext) :
// Empty drawing - return empty JSON like iOS
promise.resolve("""{"pages":{}}""")
}
} else {
// View not found - likely unmounted during auto-save, return null gracefully
android.util.Log.w("MobileInkModule", "getBase64Data: view not found for tag $viewTag")
} catch (e: Exception) {
// Don't reject on exceptions either - gracefully return null
android.util.Log.e("MobileInkModule", "getBase64Data: exception", e)
promise.resolve(null)
}
} catch (e: Exception) {
// Don't reject on exceptions either - gracefully return null
android.util.Log.e("MobileInkModule", "getBase64Data: exception", e)
promise.resolve(null)
}
}
}
Expand Down Expand Up @@ -319,18 +322,16 @@ class MobileInkModule(private val reactContext: ReactApplicationContext) :
return@runOnUiQueueThread
}

// Serialize on the UI queue (deserialize/serialize already coordinate
// with the GL thread inside the view), then move the file write to a
// background executor so it doesn't block the UI thread.
val data: ByteArray? = try {
view.serializeDrawing()
} catch (e: Exception) {
android.util.Log.e("MobileInkModule", "persistEngineToFile: serialize exception", e)
promise.reject("GET_DATA_ERROR", e.message ?: "serialize failed", e)
return@runOnUiQueueThread
}

// Serialization and the file write both run on the background
// executor; the view copies strokes on its GL thread first.
executor.execute {
val data: ByteArray? = try {
view.serializeDrawing()
} catch (e: Exception) {
android.util.Log.e("MobileInkModule", "persistEngineToFile: serialize exception", e)
promise.reject("GET_DATA_ERROR", e.message ?: "serialize failed", e)
return@execute
}
try {
val body = if (data != null && data.isNotEmpty()) {
val base64 = Base64.encodeToString(data, Base64.NO_WRAP)
Expand Down
5 changes: 5 additions & 0 deletions cpp/SkiaDrawingEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,11 @@ void SkiaDrawingEngine::setTool(const char* toolType) {
}
}

std::vector<Stroke> SkiaDrawingEngine::snapshotDrawing() {
std::lock_guard<std::recursive_mutex> lock(stateMutex_);
return strokes_;
}

std::vector<uint8_t> SkiaDrawingEngine::serializeDrawing() {
std::lock_guard<std::recursive_mutex> lock(stateMutex_);

Expand Down
2 changes: 2 additions & 0 deletions cpp/SkiaDrawingEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ class SkiaDrawingEngine {

// Serialization
std::vector<uint8_t> serializeDrawing();
// Copy committed strokes under the state lock; encode the independent copy off-thread.
std::vector<Stroke> snapshotDrawing();
bool deserializeDrawing(const std::vector<uint8_t>& data);

// State queries
Expand Down
55 changes: 44 additions & 11 deletions ios/MobileInkModule/MobileInkBridge.mm
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
#pragma clang diagnostic pop

#include <algorithm>
#include <climits>
#include <cmath>
#include <unordered_map>

Expand Down Expand Up @@ -601,24 +602,56 @@ void getSelectionBounds(void* engine, float* outBounds) {
}
}

// Serialization operations
uint8_t* serializeDrawing(void* engine, int* outSize) {
if (!engine || !outSize) {
*outSize = 0;
// Copies serialized bytes into a buffer Swift frees with freeSerializedData.
// Empty data yields nullptr with *outSize == 0; -1 marks a failure.
static uint8_t* copyToSerializedBuffer(const std::vector<uint8_t>& data, int* outSize) {
if (data.size() > INT_MAX) {
*outSize = -1;
return nullptr;
}

std::vector<uint8_t> data = static_cast<SkiaDrawingEngine*>(engine)->serializeDrawing();
*outSize = static_cast<int>(data.size());
if (data.empty()) return nullptr;
uint8_t* buffer = new uint8_t[data.size()];
std::memcpy(buffer, data.data(), data.size());
return buffer;
}

if (data.empty()) {
// A snapshot owns its strokes independently of the view/engine lifetime. The UI
// thread only copies state; serialization must not hold the live engine's lock.
void* snapshotDrawing(void* engine) {
if (!engine) return nullptr;
try {
return new std::vector<nativedrawing::Stroke>(
static_cast<SkiaDrawingEngine*>(engine)->snapshotDrawing());
} catch (...) { return nullptr; }
}

uint8_t* serializeDrawingSnapshot(void* snapshot, int* outSize) {
if (!outSize) return nullptr;
*outSize = -1;
if (!snapshot) return nullptr;
try {
nativedrawing::DrawingSerialization serializer;
return copyToSerializedBuffer(
serializer.serialize(*static_cast<std::vector<nativedrawing::Stroke>*>(snapshot)), outSize);
} catch (...) {
*outSize = -1;
return nullptr;
}
}

// Allocate buffer that Swift must free using freeSerializedData
uint8_t* buffer = new uint8_t[data.size()];
std::memcpy(buffer, data.data(), data.size());
return buffer;
void freeDrawingSnapshot(void* snapshot) {
delete static_cast<std::vector<nativedrawing::Stroke>*>(snapshot);
}

// Serialization operations
uint8_t* serializeDrawing(void* engine, int* outSize) {
if (!outSize) return nullptr;
if (!engine) {
*outSize = 0;
return nullptr;
}
return copyToSerializedBuffer(static_cast<SkiaDrawingEngine*>(engine)->serializeDrawing(), outSize);
}

void freeSerializedData(uint8_t* data) {
Expand Down
Loading
Loading