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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ jobs:
- name: Run selection history regression test
run: npm run test:native:selection-smoke

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

- name: Set up Java for document JNI regression test
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'

- name: Run continuous document JNI regression test
run: npm run test:native:document-jni

- name: Build package artifacts
run: npm run build

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

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

## [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.
- Added external edit history for host-owned text and image changes so their undo/redo remains chronological with ink.
- Render visible zoom regions at the required scale and release obsolete render surfaces; avoid retaining old notebooks in native gesture callbacks.
- Correct tap coordinates in centered zoomed viewports and confirm native loads before marking pages ready.
- Added native parsing of the exact selected notebook payload so recovery does not reread a stale local file.
- Ordinary multi-page notebooks no longer acquire inferred PDF page numbers during loading.

## [0.3.7] - 2026-10-02

- Added `capturePreviewOnReassign` to `ContinuousEnginePool` (default `true`). Hosts that don't use page previews can set it to `false` to skip the native render and PNG encode that runs for every captured page when the pool shifts. Page data is still captured.
Expand Down
1 change: 1 addition & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ android {
}

defaultConfig {
consumerProguardFiles "consumer-rules.pro"
minSdkVersion safeExtGet("minSdkVersion", 24)
targetSdkVersion safeExtGet("targetSdkVersion", 35)

Expand Down
3 changes: 3 additions & 0 deletions android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# JNI constructs page results and resolves these native entry points by name.
-keep class com.mathnotes.mobileink.DocumentPageData { *; }
-keep class com.mathnotes.mobileink.DocumentCodec { *; }
1 change: 1 addition & 0 deletions android/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ add_library(
SHARED
${NATIVE_DRAWING_SOURCES}
jni_bridge.cpp
document_bridge.cpp
)

# Link Skia library
Expand Down
130 changes: 130 additions & 0 deletions android/src/main/cpp/document_bridge.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#include <jni.h>
#include "ContinuousDocumentPages.h"
#include "DrawingSerialization.h"
#include <stdexcept>
#include <limits>

using namespace nativedrawing;
namespace {
void check(JNIEnv* env) {
if (env->ExceptionCheck()) throw std::runtime_error("Document JNI allocation failed");
}
ContinuousDocumentPage decode(JNIEnv* env, jbyteArray data, jlongArray order, bool hasOrder) {
if (!data || !order) throw std::invalid_argument("Missing document page data");
ContinuousDocumentPage page;
std::vector<uint8_t> bytes(env->GetArrayLength(data));
if (!bytes.empty()) env->GetByteArrayRegion(data, 0, bytes.size(), reinterpret_cast<jbyte*>(bytes.data()));
check(env);
if (!bytes.empty()) {
DrawingSerialization serializer;
if (!serializer.deserialize(bytes, page.strokes)) throw std::invalid_argument("Invalid serialized drawing");
}
const auto count = env->GetArrayLength(order);
if (hasOrder && static_cast<size_t>(count) != page.strokes.size())
throw std::invalid_argument("Document stroke order count mismatch");
if (!hasOrder && count) throw std::invalid_argument("Unexpected document stroke order");
std::vector<jlong> values(count);
if (count) env->GetLongArrayRegion(order, 0, count, values.data());
check(env);
for (auto value : values) {
if (value < 0 || value > 9007199254740991LL) throw std::invalid_argument("Invalid document stroke order");
page.order.push_back(static_cast<uint64_t>(value));
}
return page;
}
std::vector<ContinuousDocumentPage> decodePages(JNIEnv* env, jobjectArray data, jobjectArray orders, jbooleanArray flags) {
if (!data || !orders || !flags) throw std::invalid_argument("Missing document pages");
const auto count = env->GetArrayLength(data);
if (env->GetArrayLength(orders) != count || env->GetArrayLength(flags) != count)
throw std::invalid_argument("Document page count mismatch");
std::vector<jboolean> hasOrder(count);
if (count) env->GetBooleanArrayRegion(flags, 0, count, hasOrder.data());
check(env);
std::vector<ContinuousDocumentPage> result;
result.reserve(count);
for (jsize index = 0; index < count; ++index) {
// Bound local JNI references even for very long documents.
if (env->PushLocalFrame(4) != JNI_OK) throw std::runtime_error("Cannot allocate document JNI frame");
try {
auto bytes = static_cast<jbyteArray>(env->GetObjectArrayElement(data, index));
auto order = static_cast<jlongArray>(env->GetObjectArrayElement(orders, index));
check(env);
result.push_back(decode(env, bytes, order, hasOrder[index]));
} catch (...) { env->PopLocalFrame(nullptr); throw; }
env->PopLocalFrame(nullptr);
}
return result;
}
jbyteArray encodeBytes(JNIEnv* env, const std::vector<Stroke>& strokes) {
DrawingSerialization serializer;
const auto bytes = strokes.empty() ? std::vector<uint8_t>() : serializer.serialize(strokes);
if (bytes.size() > static_cast<size_t>(std::numeric_limits<jsize>::max()))
throw std::length_error("Document payload exceeds JNI array limit");
auto result = env->NewByteArray(static_cast<jsize>(bytes.size()));
check(env);
if (!bytes.empty()) env->SetByteArrayRegion(result, 0, bytes.size(), reinterpret_cast<const jbyte*>(bytes.data()));
check(env);
return result;
}
jobjectArray encodePages(JNIEnv* env, const std::vector<ContinuousDocumentPage>& pages) {
auto type = env->FindClass("com/mathnotes/mobileink/DocumentPageData");
check(env);
auto constructor = env->GetMethodID(type, "<init>", "([B[J)V");
check(env);
auto result = env->NewObjectArray(pages.size(), type, nullptr);
check(env);
for (size_t index = 0; index < pages.size(); ++index) {
if (env->PushLocalFrame(4) != JNI_OK) throw std::runtime_error("Cannot allocate document JNI frame");
try {
const auto& page = pages[index];
auto bytes = encodeBytes(env, page.strokes);
std::vector<jlong> order(page.order.begin(), page.order.end());
auto indices = env->NewLongArray(order.size());
check(env);
if (!order.empty()) env->SetLongArrayRegion(indices, 0, order.size(), order.data());
check(env);
auto value = env->NewObject(type, constructor, bytes, indices);
check(env);
env->SetObjectArrayElement(result, index, value);
check(env);
} catch (...) { env->PopLocalFrame(nullptr); throw; }
env->PopLocalFrame(nullptr);
}
env->DeleteLocalRef(type);
return result;
}
void reject(JNIEnv* env, const std::exception& error) {
if (env->ExceptionCheck()) return;
auto type = env->FindClass("java/lang/IllegalArgumentException");
if (type) { env->ThrowNew(type, error.what()); env->DeleteLocalRef(type); }
}
}
extern "C" JNIEXPORT jbyteArray JNICALL Java_com_mathnotes_mobileink_DocumentCodec_compose(
JNIEnv* env, jobject, jobjectArray data, jobjectArray orders, jbooleanArray flags, jfloat height) {
try { return encodeBytes(env, composeContinuousDocument(decodePages(env, data, orders, flags), 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_partition(
JNIEnv* env, jobject, jbyteArray data, jlongArray order, jboolean hasOrder, jint count, jfloat height) {
try {
if (count < 1 || count > 100000) throw std::invalid_argument("Invalid document page count");
return encodePages(env, partitionContinuousDocument(decode(env, data, order, hasOrder).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 {
if (!requested) throw std::invalid_argument("Missing export page indices");
std::vector<jint> raw(env->GetArrayLength(requested));
if (!raw.empty()) env->GetIntArrayRegion(requested, 0, raw.size(), raw.data());
check(env);
const auto projected = projectContinuousDocumentPages(decodePages(env, data, orders, flags), height,
std::vector<int>(raw.begin(), raw.end()));
std::vector<ContinuousDocumentPage> pages;
for (const auto& strokes : projected) pages.push_back({strokes, {}});
return encodePages(env, pages);
} catch (const std::exception& error) { reject(env, error); return nullptr; }
catch (...) { reject(env, std::runtime_error("Unknown document codec failure")); return nullptr; }
}
64 changes: 47 additions & 17 deletions android/src/main/cpp/jni_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,16 @@ sk_sp<SkImage> makeRedBlueSwappedImage(const sk_sp<SkImage>& image) {
return surface->makeImageSnapshot();
}

void renderWithDisplayColorOrdering(SkiaDrawingEngine* engine, SkCanvas* canvas) {
void renderWithDisplayColorOrdering(SkiaDrawingEngine* engine, SkCanvas* canvas, bool cropped = false) {
if (!engine || !canvas) {
return;
}

SkPaint outputPaint;
outputPaint.setColorFilter(makeRedBlueSwapFilter());
canvas->saveLayer(nullptr, &outputPaint);
engine->render(canvas);
if (cropped) engine->renderVisibleViewport(canvas);
else engine->render(canvas);
canvas->restore();
}

Expand Down Expand Up @@ -356,8 +357,9 @@ struct DrawingContext {
int ganeshSamples = -1;
int ganeshStencil = -1;

DrawingContext(int w, int h) {
engine = std::make_unique<SkiaDrawingEngine>(w, h);
const bool continuousDocument;
DrawingContext(int w, int h, bool continuous = false) : continuousDocument(continuous) {
engine = std::make_unique<SkiaDrawingEngine>(w, h, continuous);
}

~DrawingContext() {
Expand All @@ -374,12 +376,19 @@ extern "C" {
// Engine lifecycle
JNIEXPORT jlong JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_createDrawingEngine(
JNIEnv* env, jobject obj, jint width, jint height) {
JNIEnv* env, jobject obj, jint width, jint height, jboolean continuous) {

auto* ctx = new DrawingContext(width, height);
auto* ctx = new DrawingContext(width, height, continuous == JNI_TRUE);
return reinterpret_cast<jlong>(ctx);
}

JNIEXPORT jboolean JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_resizeContinuousDrawingEngine(
JNIEnv* env, jobject obj, jlong contextPtr, jint width, jint height) {
auto* ctx = reinterpret_cast<DrawingContext*>(contextPtr);
return ctx && ctx->engine && ctx->engine->resizeDocument(width, height) ? JNI_TRUE : JNI_FALSE;
}

JNIEXPORT void JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_destroyDrawingEngine(
JNIEnv* env, jobject obj, jlong contextPtr) {
Expand Down Expand Up @@ -461,6 +470,12 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_updateHoldShapePreview(
}

// Canvas operations
JNIEXPORT jboolean JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_clearDocumentPageNative(JNIEnv* env, jobject obj, jlong contextPtr, jint pageIndex, jfloat pageHeight) {
auto* ctx = reinterpret_cast<DrawingContext*>(contextPtr);
return ctx && ctx->engine && ctx->engine->clearDocumentPage(pageIndex, pageHeight) ? JNI_TRUE : JNI_FALSE;
}

JNIEXPORT void JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_clearCanvas(JNIEnv* env, jobject obj, jlong contextPtr) {
auto* ctx = reinterpret_cast<DrawingContext*>(contextPtr);
Expand All @@ -469,20 +484,35 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_clearCanvas(JNIEnv* env, jobjec
}
}

JNIEXPORT void JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_undoStroke(JNIEnv* env, jobject obj, jlong contextPtr) {
JNIEXPORT jboolean JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_recordExternalEditNative(
JNIEnv* env, jobject, jlong contextPtr, jstring before, jstring after) {
auto* ctx = reinterpret_cast<DrawingContext*>(contextPtr);
if (ctx && ctx->engine) {
ctx->engine->undo();
}
if (!ctx || !ctx->engine || !before || !after) return JNI_FALSE;
const char* beforeBytes = env->GetStringUTFChars(before, nullptr);
if (!beforeBytes) return JNI_FALSE;
const char* afterBytes = env->GetStringUTFChars(after, nullptr);
if (!afterBytes) { env->ReleaseStringUTFChars(before, beforeBytes); return JNI_FALSE; }
bool result = ctx->engine->commitExternalEdit(beforeBytes, afterBytes);
env->ReleaseStringUTFChars(before, beforeBytes);
env->ReleaseStringUTFChars(after, afterBytes);
return result ? JNI_TRUE : JNI_FALSE;
}

JNIEXPORT void JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_redoStroke(JNIEnv* env, jobject obj, jlong contextPtr) {
JNIEXPORT jstring JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_undoStroke(JNIEnv* env, jobject, jlong contextPtr) {
auto* ctx = reinterpret_cast<DrawingContext*>(contextPtr);
if (ctx && ctx->engine) {
ctx->engine->redo();
}
if (!ctx || !ctx->engine) return nullptr;
const auto payload = ctx->engine->undo();
return payload.empty() ? nullptr : env->NewStringUTF(payload.c_str());
}

JNIEXPORT jstring JNICALL
Java_com_mathnotes_mobileink_MobileInkCanvasView_redoStroke(JNIEnv* env, jobject, jlong contextPtr) {
auto* ctx = reinterpret_cast<DrawingContext*>(contextPtr);
if (!ctx || !ctx->engine) return nullptr;
const auto payload = ctx->engine->redo();
return payload.empty() ? nullptr : env->NewStringUTF(payload.c_str());
}

// Tool settings
Expand Down Expand Up @@ -883,7 +913,7 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_renderGaneshToCurrentSurface(
}

SkCanvas* canvas = ctx->ganeshSurface->getCanvas();
renderWithDisplayColorOrdering(ctx->engine.get(), canvas);
renderWithDisplayColorOrdering(ctx->engine.get(), canvas, ctx->continuousDocument);
ctx->ganeshContext->flushAndSubmit(ctx->ganeshSurface.get());
return JNI_TRUE;
}
Expand Down
Loading
Loading