diff --git a/README.md b/README.md
index c4f7de8..e126890 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ Production-grade React Native ink primitives extracted from the MathNotes canvas
-`@mathnotes/mobile-ink` is an iOS-first native drawing engine for React Native apps. It gives you Apple Pencil input, Skia/Metal rendering, stroke serialization, selection, zoom, momentum scrolling, and a continuous notebook surface backed by a fixed native engine pool.
+`@mathnotes/mobile-ink` is a native drawing engine for React Native apps. It gives you Apple Pencil and stylus input, Skia-backed native rendering, stroke serialization, selection, zoom, momentum scrolling, and a continuous notebook surface backed by a fixed native engine pool.
## A Note To Contributors
@@ -37,12 +37,12 @@ mobile-ink is currently used in production in MathNotes: https://apps.apple.com/
| Area | Current support |
| --- | --- |
| iOS Apple Pencil drawing | Used in production |
-| Native rendering | Custom `MTKView` backed by C++ Skia/Metal |
+| Android stylus/finger drawing | Native Android view backed by the same C++ engine |
+| Native rendering | iOS `MTKView` with Skia/Metal; Android `GLSurfaceView` with Skia/OpenGL upload |
| Continuous notebooks | Fixed native engine pool with momentum scroll and pinch zoom |
| Tools | Pen, highlighter, crayon, calligraphy, eraser, selection, and shape recognition |
| Serialization | JSON notebook payloads plus native page load/save/export helpers |
| Example app | Expo dev-client app with blank continuous notebook, tools, selection, save/reload, and zoom |
-| Android | Not supported yet |
| Expo Go | Not supported because this package includes native code |
## Demos
@@ -68,7 +68,7 @@ npm install @mathnotes/mobile-ink \
cd ios && pod install
```
-For Expo apps, use a dev client or prebuild. Expo Go cannot load this native module.
+For Expo apps, use a dev client or prebuild. Expo Go cannot load this native module. Android consumers need the normal React Native Android toolchain because the package builds a Kotlin/C++ Android library.
Your app Babel config must include the Reanimated/Worklets plugin expected by your React Native/Reanimated version. For Expo SDK 54/Reanimated 4:
@@ -121,7 +121,7 @@ export function Notebook() {
## Public Surface
-- `NativeInkCanvas`: low-level native Skia/Metal drawing view.
+- `NativeInkCanvas`: low-level native Skia drawing view.
- `ZoomableInkViewport`: production pinch, pan, momentum, focal-point zoom, and Apple Pencil/finger gesture routing.
- `ContinuousEnginePool`: fixed-size native canvas pool for continuous notebooks.
- `InfiniteInkCanvas`: full vertical continuous notebook shell with pooled native engines, trailing page creation, dirty-page serialization, zoom, momentum scroll, and generic page backgrounds.
@@ -144,6 +144,12 @@ For a simulator:
npx expo run:ios
```
+For Android:
+
+```sh
+npx expo run:android
+```
+
## Documentation
- [Architecture](docs/architecture.md)
@@ -159,7 +165,7 @@ Near-term work is focused on making the public package easier to adopt and easie
- Tighten selection transform performance for large stroke groups.
- Improve edge-case zoom behavior near page and canvas boundaries.
- Continue hardening the example app as a small regression harness.
-- Explore Android after the iOS API surface has settled.
+- Keep Android behavior aligned with the iOS API surface as new native capabilities land.
## Development
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..6bd26ee
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,2 @@
+/.cxx/
+/build/
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..b5793ef
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,92 @@
+buildscript {
+ def kotlinVersion = rootProject.ext.has("kotlinVersion")
+ ? rootProject.ext.get("kotlinVersion")
+ : "2.1.20"
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.10.1")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
+ }
+}
+
+def safeExtGet(prop, fallback) {
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+}
+
+static def findNodeModules(baseDir) {
+ def basePath = baseDir.toPath().normalize()
+ while (basePath != null) {
+ def nodeModulesPath = basePath.resolve("node_modules").toFile()
+ def reactNativePath = new File(nodeModulesPath, "react-native")
+ if (nodeModulesPath.exists() && reactNativePath.exists()) {
+ return nodeModulesPath.absolutePath
+ }
+ basePath = basePath.getParent()
+ }
+ throw new GradleException("@mathnotes/mobile-ink: failed to find node_modules")
+}
+
+def reactNativeArchitectures() {
+ def value = project.getProperties().get("reactNativeArchitectures")
+ return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
+}
+
+apply plugin: "com.android.library"
+apply plugin: "kotlin-android"
+
+def nodeModules = findNodeModules(projectDir)
+
+android {
+ namespace "com.mathnotes.mobileink"
+ compileSdkVersion safeExtGet("compileSdkVersion", 35)
+
+ if (rootProject.ext.has("ndkPath")) {
+ ndkPath rootProject.ext.ndkPath
+ }
+ if (rootProject.ext.has("ndkVersion")) {
+ ndkVersion rootProject.ext.ndkVersion
+ }
+
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 24)
+ targetSdkVersion safeExtGet("targetSdkVersion", 35)
+
+ externalNativeBuild {
+ cmake {
+ cppFlags "-std=c++17", "-frtti", "-fexceptions", "-DSK_BUILD_FOR_ANDROID", "-DON_ANDROID", "-DONANDROID"
+ arguments "-DNODE_MODULES_DIR=${nodeModules}",
+ "-DANDROID_STL=c++_shared",
+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
+ abiFilters(*reactNativeArchitectures())
+ }
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ externalNativeBuild {
+ cmake {
+ path "src/main/cpp/CMakeLists.txt"
+ version "3.22.1"
+ }
+ }
+
+ packagingOptions {
+ excludes = [
+ "**/libc++_shared.so",
+ "META-INF/**"
+ ]
+ }
+}
+
+dependencies {
+ implementation "com.facebook.react:react-android"
+}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..94cbbcf
--- /dev/null
+++ b/android/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/android/src/main/cpp/CMakeLists.txt b/android/src/main/cpp/CMakeLists.txt
new file mode 100644
index 0000000..fec73cd
--- /dev/null
+++ b/android/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,64 @@
+cmake_minimum_required(VERSION 3.13)
+project(mobileink)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+# Node modules path passed from Gradle
+set(NODE_MODULES_DIR ${NODE_MODULES_DIR})
+
+# Skia paths from react-native-skia
+set(SKIA_DIR "${NODE_MODULES_DIR}/@shopify/react-native-skia")
+set(SKIA_INCLUDE_DIR "${SKIA_DIR}/cpp/skia")
+set(SKIA_LIB_DIR "${SKIA_DIR}/libs/android/${ANDROID_ABI}")
+
+# Shared C++ drawing engine from this package root.
+set(NATIVE_DRAWING_CPP_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../../cpp")
+
+message(STATUS "mobileink: NODE_MODULES_DIR=${NODE_MODULES_DIR}")
+message(STATUS "mobileink: SKIA_DIR=${SKIA_DIR}")
+message(STATUS "mobileink: NATIVE_DRAWING_CPP_DIR=${NATIVE_DRAWING_CPP_DIR}")
+message(STATUS "mobileink: ANDROID_ABI=${ANDROID_ABI}")
+
+# Include directories
+include_directories(
+ ${SKIA_INCLUDE_DIR}
+ ${SKIA_INCLUDE_DIR}/include
+ ${SKIA_INCLUDE_DIR}/modules/pathops/include
+ ${NATIVE_DRAWING_CPP_DIR}
+)
+
+# Add source files
+add_library(
+ mobileink
+ SHARED
+ ${NATIVE_DRAWING_CPP_DIR}/SkiaDrawingEngine.cpp
+ ${NATIVE_DRAWING_CPP_DIR}/DrawingSelection.cpp
+ ${NATIVE_DRAWING_CPP_DIR}/BackgroundRenderer.cpp
+ ${NATIVE_DRAWING_CPP_DIR}/DrawingSerialization.cpp
+ ${NATIVE_DRAWING_CPP_DIR}/PathRenderer.cpp
+ ${NATIVE_DRAWING_CPP_DIR}/EraserRenderer.cpp
+ ${NATIVE_DRAWING_CPP_DIR}/StrokeSplitter.cpp
+ ${NATIVE_DRAWING_CPP_DIR}/BatchExporter.cpp
+ ${NATIVE_DRAWING_CPP_DIR}/ActiveStrokeRenderer.cpp
+ jni_bridge.cpp
+)
+
+# Link Skia library
+add_library(skia STATIC IMPORTED)
+set_target_properties(skia PROPERTIES IMPORTED_LOCATION "${SKIA_LIB_DIR}/libskia.a")
+
+add_library(pathops STATIC IMPORTED)
+set_target_properties(pathops PROPERTIES IMPORTED_LOCATION "${SKIA_LIB_DIR}/libpathops.a")
+
+# Link libraries
+target_link_libraries(
+ mobileink
+ skia
+ pathops
+ android
+ log
+ GLESv2
+ EGL
+ jnigraphics
+)
diff --git a/android/src/main/cpp/jni_bridge.cpp b/android/src/main/cpp/jni_bridge.cpp
new file mode 100644
index 0000000..4b85306
--- /dev/null
+++ b/android/src/main/cpp/jni_bridge.cpp
@@ -0,0 +1,614 @@
+#include
+#include
+#include
+#include
+#include "SkiaDrawingEngine.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define LOG_TAG "MobileInk"
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
+
+using namespace nativedrawing;
+
+// DrawingContext holds the engine and a raster surface for CPU rendering
+struct DrawingContext {
+ std::unique_ptr engine;
+ sk_sp surface;
+ int width;
+ int height;
+
+ DrawingContext(int w, int h) : width(w), height(h) {
+ engine = std::make_unique(w, h);
+
+ // Create raster surface for CPU rendering
+ SkImageInfo info = SkImageInfo::MakeN32Premul(w, h);
+ surface = SkSurfaces::Raster(info);
+
+ }
+};
+
+extern "C" {
+
+// Engine lifecycle
+JNIEXPORT jlong JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_createDrawingEngine(
+ JNIEnv* env, jobject obj, jint width, jint height) {
+
+ auto* ctx = new DrawingContext(width, height);
+ return reinterpret_cast(ctx);
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_destroyDrawingEngine(
+ JNIEnv* env, jobject obj, jlong contextPtr) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ delete ctx;
+}
+
+// Touch handling with stylus support (pressure, azimuth, altitude)
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_touchBegan(
+ JNIEnv* env, jobject obj, jlong contextPtr,
+ jfloat x, jfloat y, jfloat pressure, jfloat azimuth, jfloat altitude,
+ jlong timestamp, jboolean isStylusInput) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->touchBegan(x, y, pressure, azimuth, altitude, static_cast(timestamp), isStylusInput == JNI_TRUE);
+ } else {
+ LOGE("touchBegan: context or engine is null! ctx=%p", ctx);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_touchMoved(
+ JNIEnv* env, jobject obj, jlong contextPtr,
+ jfloat x, jfloat y, jfloat pressure, jfloat azimuth, jfloat altitude,
+ jlong timestamp, jboolean isStylusInput) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->touchMoved(x, y, pressure, azimuth, altitude, static_cast(timestamp), isStylusInput == JNI_TRUE);
+ } else {
+ LOGE("touchMoved: context or engine is null!");
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_touchEnded(JNIEnv* env, jobject obj, jlong contextPtr, jlong timestamp) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->touchEnded(static_cast(timestamp));
+ } else {
+ LOGE("touchEnded: context or engine is null!");
+ }
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_updateHoldShapePreview(
+ JNIEnv* env, jobject obj, jlong contextPtr, jlong timestamp) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ return ctx->engine->updateHoldShapePreview(static_cast(timestamp)) ? JNI_TRUE : JNI_FALSE;
+ }
+
+ return JNI_FALSE;
+}
+
+// Canvas operations
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_clearCanvas(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->clear();
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_undoStroke(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->undo();
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_redoStroke(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->redo();
+ }
+}
+
+// Tool settings
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_setStrokeColor(
+ JNIEnv* env, jobject obj, jlong contextPtr, jint color) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ // Android color is ARGB, Skia uses ARGB too but we need SkColor format
+ ctx->engine->setStrokeColor(static_cast(color));
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_setStrokeWidth(
+ JNIEnv* env, jobject obj, jlong contextPtr, jfloat width) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->setStrokeWidth(width);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_setTool(
+ JNIEnv* env, jobject obj, jlong contextPtr, jstring toolType) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ const char* toolStr = env->GetStringUTFChars(toolType, nullptr);
+ ctx->engine->setTool(toolStr);
+ env->ReleaseStringUTFChars(toolType, toolStr);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_setToolWithParams(
+ JNIEnv* env, jobject obj, jlong contextPtr,
+ jstring toolType, jfloat width, jint color, jstring eraserMode) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ const char* toolStr = env->GetStringUTFChars(toolType, nullptr);
+ const char* eraserStr = eraserMode ? env->GetStringUTFChars(eraserMode, nullptr) : "";
+
+ ctx->engine->setToolWithParams(toolStr, width, static_cast(color), eraserStr);
+
+ env->ReleaseStringUTFChars(toolType, toolStr);
+ if (eraserMode) {
+ env->ReleaseStringUTFChars(eraserMode, eraserStr);
+ }
+ }
+}
+
+// Eraser cursor for pixel eraser visualization
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_setEraserCursor(
+ JNIEnv* env, jobject obj, jlong contextPtr,
+ jfloat x, jfloat y, jfloat radius, jboolean visible) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->setEraserCursor(x, y, radius, visible == JNI_TRUE);
+ }
+}
+
+// Background type for pattern rendering
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_setBackgroundType(
+ JNIEnv* env, jobject obj, jlong contextPtr, jstring backgroundType) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ const char* typeStr = env->GetStringUTFChars(backgroundType, nullptr);
+ ctx->engine->setBackgroundType(typeStr);
+ env->ReleaseStringUTFChars(backgroundType, typeStr);
+ }
+}
+
+// PDF background bitmap - render PDF in Kotlin, pass to C++ as SkImage
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_setPdfBackgroundBitmap(
+ JNIEnv* env, jobject obj, jlong contextPtr, jobject bitmap) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (!ctx || !ctx->engine) {
+ LOGE("setPdfBackgroundBitmap: context or engine is null");
+ return;
+ }
+
+ if (bitmap == nullptr) {
+ ctx->engine->setPdfBackgroundImage(nullptr);
+ return;
+ }
+
+ // Get bitmap info
+ AndroidBitmapInfo info;
+ if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
+ LOGE("setPdfBackgroundBitmap: failed to get bitmap info");
+ return;
+ }
+
+ // Lock pixels
+ void* pixels = nullptr;
+ if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
+ LOGE("setPdfBackgroundBitmap: failed to lock pixels");
+ return;
+ }
+
+ // Create SkImage from bitmap pixels
+ // Android ARGB_8888 is compatible with Skia N32 (BGRA on little-endian)
+ SkImageInfo skInfo = SkImageInfo::MakeN32Premul(info.width, info.height);
+
+ // Copy pixel data since we need to unlock the bitmap
+ size_t dataSize = info.height * info.stride;
+ sk_sp data = SkData::MakeWithCopy(pixels, dataSize);
+
+ AndroidBitmap_unlockPixels(env, bitmap);
+
+ // Create the image from the copied data
+ sk_sp image = SkImages::RasterFromData(skInfo, data, info.stride);
+
+ if (image) {
+ ctx->engine->setPdfBackgroundImage(image);
+ } else {
+ LOGE("setPdfBackgroundBitmap: failed to create SkImage");
+ }
+}
+
+// Selection operations
+JNIEXPORT jboolean JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_selectStrokeAt(
+ JNIEnv* env, jobject obj, jlong contextPtr, jfloat x, jfloat y) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ return ctx->engine->selectStrokeAt(x, y) ? JNI_TRUE : JNI_FALSE;
+ }
+ return JNI_FALSE;
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_selectShapeStrokeAt(
+ JNIEnv* env, jobject obj, jlong contextPtr, jfloat x, jfloat y) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ return ctx->engine->selectShapeStrokeAt(x, y) ? JNI_TRUE : JNI_FALSE;
+ }
+ return JNI_FALSE;
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_clearSelection(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->clearSelection();
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_deleteSelection(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->deleteSelection();
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_copySelection(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->copySelection();
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_pasteSelection(
+ JNIEnv* env, jobject obj, jlong contextPtr, jfloat offsetX, jfloat offsetY) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->pasteSelection(offsetX, offsetY);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_moveSelection(
+ JNIEnv* env, jobject obj, jlong contextPtr, jfloat dx, jfloat dy) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->moveSelection(dx, dy);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_finalizeMove(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ ctx->engine->finalizeMove();
+ }
+}
+
+JNIEXPORT jint JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_getSelectionCount(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ return ctx->engine->getSelectionCount();
+ }
+ return 0;
+}
+
+JNIEXPORT jfloatArray JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_getSelectionBounds(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ std::vector bounds = ctx->engine->getSelectionBounds();
+ jfloatArray result = env->NewFloatArray(4);
+ env->SetFloatArrayRegion(result, 0, 4, bounds.data());
+ return result;
+ }
+ return nullptr;
+}
+
+// State queries
+JNIEXPORT jboolean JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_canUndo(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ return ctx->engine->canUndo() ? JNI_TRUE : JNI_FALSE;
+ }
+ return JNI_FALSE;
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_canRedo(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ return ctx->engine->canRedo() ? JNI_TRUE : JNI_FALSE;
+ }
+ return JNI_FALSE;
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_isEmpty(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ return ctx->engine->isEmpty() ? JNI_TRUE : JNI_FALSE;
+ }
+ return JNI_TRUE;
+}
+
+// Serialization
+JNIEXPORT jbyteArray JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_serializeDrawing(JNIEnv* env, jobject obj, jlong contextPtr) {
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine) {
+ std::vector data = ctx->engine->serializeDrawing();
+ jbyteArray result = env->NewByteArray(data.size());
+ env->SetByteArrayRegion(result, 0, data.size(), reinterpret_cast(data.data()));
+ return result;
+ }
+ LOGE("serializeDrawing: ctx or engine is null!");
+ return nullptr;
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_deserializeDrawing(
+ JNIEnv* env, jobject obj, jlong contextPtr, jbyteArray data) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx && ctx->engine && data) {
+ jsize len = env->GetArrayLength(data);
+ jbyte* bytes = env->GetByteArrayElements(data, nullptr);
+
+ std::vector vec(reinterpret_cast(bytes),
+ reinterpret_cast(bytes) + len);
+ ctx->engine->deserializeDrawing(vec);
+
+ env->ReleaseByteArrayElements(data, bytes, JNI_ABORT);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_renderToPixels(
+ JNIEnv* env, jobject obj, jlong contextPtr, jobject bitmap) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (!ctx || !ctx->engine || !ctx->surface) {
+ LOGE("renderToPixels: invalid context");
+ return;
+ }
+
+ // Get bitmap info
+ AndroidBitmapInfo info;
+ if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
+ LOGE("renderToPixels: failed to get bitmap info");
+ return;
+ }
+
+ // Lock pixels
+ void* pixels = nullptr;
+ if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
+ LOGE("renderToPixels: failed to lock pixels");
+ return;
+ }
+
+ // Render to surface - engine handles background clearing and pattern rendering
+ SkCanvas* canvas = ctx->surface->getCanvas();
+ ctx->engine->render(canvas);
+
+ // Read pixels from surface into bitmap
+ SkImageInfo dstInfo = SkImageInfo::MakeN32Premul(info.width, info.height);
+ ctx->surface->readPixels(dstInfo, pixels, info.stride, 0, 0);
+
+ AndroidBitmap_unlockPixels(env, bitmap);
+}
+
+// Alternative: render to byte array for cases where bitmap isn't convenient
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_renderToByteArray(
+ JNIEnv* env, jobject obj, jlong contextPtr, jbyteArray pixels, jint width, jint height) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (!ctx || !ctx->engine || !ctx->surface) {
+ LOGE("renderToByteArray: invalid context");
+ return;
+ }
+
+ // Render to surface - engine handles background clearing and pattern rendering
+ SkCanvas* canvas = ctx->surface->getCanvas();
+ ctx->engine->render(canvas);
+
+ // Get pixel data
+ jbyte* pixelData = env->GetByteArrayElements(pixels, nullptr);
+
+ SkImageInfo dstInfo = SkImageInfo::MakeN32Premul(width, height);
+ ctx->surface->readPixels(dstInfo, pixelData, width * 4, 0, 0);
+
+ env->ReleaseByteArrayElements(pixels, pixelData, 0);
+}
+
+// Resize the drawing context when view size changes
+JNIEXPORT void JNICALL
+Java_com_mathnotes_mobileink_MobileInkCanvasView_resizeEngine(
+ JNIEnv* env, jobject obj, jlong contextPtr, jint width, jint height) {
+
+ auto* ctx = reinterpret_cast(contextPtr);
+ if (ctx) {
+ // Recreate surface with new size
+ SkImageInfo info = SkImageInfo::MakeN32Premul(width, height);
+ ctx->surface = SkSurfaces::Raster(info);
+ ctx->width = width;
+ ctx->height = height;
+
+ // Note: The engine itself doesn't need resizing as strokes are stored
+ // in absolute coordinates. We just need the new render surface.
+ }
+}
+
+// Batch export multiple pages to PNG images
+// This is a static method on MobileInkModule (not tied to a view instance)
+// pagesDataArray: Array of byte arrays with serialized drawing data for each page
+// backgroundTypes: Array of background type strings ("plain", "lined", "grid", "pdf")
+// width, height: Canvas dimensions
+// scale: Export scale factor
+// Returns: Array of base64 PNG data URIs
+JNIEXPORT jobjectArray JNICALL
+Java_com_mathnotes_mobileink_MobileInkModule_nativeBatchExportPages(
+ JNIEnv* env, jclass clazz,
+ jobjectArray pagesDataArray,
+ jobjectArray backgroundTypesArray,
+ jobjectArray pdfBackgroundsArray,
+ jintArray pageIndicesArray,
+ jint width, jint height, jfloat scale) {
+
+ int numPages = env->GetArrayLength(pagesDataArray);
+ if (numPages == 0) {
+ // Return empty array
+ jclass stringClass = env->FindClass("java/lang/String");
+ return env->NewObjectArray(0, stringClass, nullptr);
+ }
+
+ // Create temporary engine for batch processing
+ auto engine = std::make_unique(width, height);
+
+ // Prepare vectors for batch export
+ std::vector> pagesData;
+ std::vector bgTypes;
+ std::vector> pdfBackgrounds;
+ pagesData.reserve(numPages);
+ bgTypes.reserve(numPages);
+ pdfBackgrounds.reserve(numPages);
+
+ std::vector pageIndices(numPages);
+ if (pageIndicesArray != nullptr && env->GetArrayLength(pageIndicesArray) >= numPages) {
+ jint* rawPageIndices = env->GetIntArrayElements(pageIndicesArray, nullptr);
+ if (rawPageIndices != nullptr) {
+ for (int i = 0; i < numPages; i++) {
+ pageIndices[i] = rawPageIndices[i];
+ }
+ env->ReleaseIntArrayElements(pageIndicesArray, rawPageIndices, JNI_ABORT);
+ }
+ } else {
+ for (int i = 0; i < numPages; i++) {
+ pageIndices[i] = i;
+ }
+ }
+
+ for (int i = 0; i < numPages; i++) {
+ std::vector pageData;
+ auto pageBytes = (jbyteArray)env->GetObjectArrayElement(pagesDataArray, i);
+ if (pageBytes != nullptr) {
+ jsize len = env->GetArrayLength(pageBytes);
+ jbyte* bytes = env->GetByteArrayElements(pageBytes, nullptr);
+ if (bytes != nullptr) {
+ if (len > 0) {
+ pageData.assign(
+ reinterpret_cast(bytes),
+ reinterpret_cast(bytes) + len
+ );
+ }
+ env->ReleaseByteArrayElements(pageBytes, bytes, JNI_ABORT);
+ }
+ env->DeleteLocalRef(pageBytes);
+ }
+
+ pagesData.push_back(std::move(pageData));
+
+ // Get background type
+ jstring bgTypeStr = (jstring)env->GetObjectArrayElement(backgroundTypesArray, i);
+ const char* bgType = env->GetStringUTFChars(bgTypeStr, nullptr);
+ bgTypes.push_back(bgType ? bgType : "plain");
+ env->ReleaseStringUTFChars(bgTypeStr, bgType);
+ env->DeleteLocalRef(bgTypeStr);
+
+ sk_sp pdfImage = nullptr;
+ if (pdfBackgroundsArray != nullptr) {
+ jobject bitmap = env->GetObjectArrayElement(pdfBackgroundsArray, i);
+ if (bitmap != nullptr) {
+ AndroidBitmapInfo info;
+ if (AndroidBitmap_getInfo(env, bitmap, &info) == ANDROID_BITMAP_RESULT_SUCCESS) {
+ void* pixels = nullptr;
+ if (AndroidBitmap_lockPixels(env, bitmap, &pixels) == ANDROID_BITMAP_RESULT_SUCCESS) {
+ SkImageInfo skInfo = SkImageInfo::MakeN32Premul(info.width, info.height);
+ size_t dataSize = info.height * info.stride;
+ sk_sp data = SkData::MakeWithCopy(pixels, dataSize);
+ AndroidBitmap_unlockPixels(env, bitmap);
+ pdfImage = SkImages::RasterFromData(skInfo, data, info.stride);
+ }
+ }
+ env->DeleteLocalRef(bitmap);
+ }
+ }
+ pdfBackgrounds.push_back(pdfImage);
+ }
+
+
+ // Call batch export on the engine
+ std::vector results = engine->batchExportPages(
+ pagesData, bgTypes, pdfBackgrounds, pageIndices, scale);
+
+ // Convert results to Java String array
+ jclass stringClass = env->FindClass("java/lang/String");
+ jobjectArray resultArray = env->NewObjectArray(numPages, stringClass, nullptr);
+
+ for (int i = 0; i < numPages && i < (int)results.size(); i++) {
+ if (!results[i].empty()) {
+ jstring resultStr = env->NewStringUTF(results[i].c_str());
+ env->SetObjectArrayElement(resultArray, i, resultStr);
+ env->DeleteLocalRef(resultStr);
+ } else {
+ // Set empty string for failed exports
+ jstring emptyStr = env->NewStringUTF("");
+ env->SetObjectArrayElement(resultArray, i, emptyStr);
+ env->DeleteLocalRef(emptyStr);
+ }
+ }
+
+ return resultArray;
+}
+
+} // extern "C"
diff --git a/android/src/main/java/com/mathnotes/mobileink/DrawingRenderer.kt b/android/src/main/java/com/mathnotes/mobileink/DrawingRenderer.kt
new file mode 100644
index 0000000..5fe250e
--- /dev/null
+++ b/android/src/main/java/com/mathnotes/mobileink/DrawingRenderer.kt
@@ -0,0 +1,169 @@
+package com.mathnotes.mobileink
+
+import android.graphics.Bitmap
+import android.opengl.GLES20
+import android.opengl.GLSurfaceView.Renderer
+import android.opengl.GLUtils
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.FloatBuffer
+import javax.microedition.khronos.egl.EGLConfig
+import javax.microedition.khronos.opengles.GL10
+
+/**
+ * Callback interface for the drawing engine host.
+ * Allows DrawingRenderer to communicate with MobileInkCanvasView without tight coupling.
+ */
+interface DrawingEngineHost {
+ /** Get current drawing engine handle */
+ fun getDrawingEngine(): Long
+
+ /** Called when surface size changes */
+ fun onSurfaceSizeChanged(width: Int, height: Int)
+
+ /** Render engine content to a bitmap */
+ fun renderEngineToPixels(engine: Long, bitmap: Bitmap)
+}
+
+/**
+ * OpenGL renderer for MobileInkCanvasView.
+ * Renders Skia drawing engine output to an OpenGL texture for display.
+ */
+class DrawingRenderer(private val host: DrawingEngineHost) : Renderer {
+ private var textureId: Int = 0
+ private var programId: Int = 0
+ private var renderBitmap: Bitmap? = null
+ private var vertexBuffer: FloatBuffer? = null
+ private var texCoordBuffer: FloatBuffer? = null
+ var backgroundColor: Int = 0xFFFFFFFF.toInt()
+
+ private var surfaceWidth: Int = 0
+ private var surfaceHeight: Int = 0
+
+ private val vertexShaderCode = """
+ attribute vec4 aPosition;
+ attribute vec2 aTexCoord;
+ varying vec2 vTexCoord;
+ void main() {
+ gl_Position = aPosition;
+ vTexCoord = aTexCoord;
+ }
+ """.trimIndent()
+
+ private val fragmentShaderCode = """
+ precision mediump float;
+ uniform sampler2D uTexture;
+ varying vec2 vTexCoord;
+ void main() {
+ gl_FragColor = texture2D(uTexture, vTexCoord);
+ }
+ """.trimIndent()
+
+ override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
+ GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f)
+
+ // Compile shaders
+ val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode)
+ val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)
+
+ programId = GLES20.glCreateProgram()
+ GLES20.glAttachShader(programId, vertexShader)
+ GLES20.glAttachShader(programId, fragmentShader)
+ GLES20.glLinkProgram(programId)
+
+ // Create texture
+ val textures = IntArray(1)
+ GLES20.glGenTextures(1, textures, 0)
+ textureId = textures[0]
+
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
+
+ // Setup vertex data for fullscreen quad
+ val vertices = floatArrayOf(
+ -1.0f, -1.0f,
+ 1.0f, -1.0f,
+ -1.0f, 1.0f,
+ 1.0f, 1.0f
+ )
+
+ val texCoords = floatArrayOf(
+ 0.0f, 1.0f,
+ 1.0f, 1.0f,
+ 0.0f, 0.0f,
+ 1.0f, 0.0f
+ )
+
+ vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4)
+ .order(ByteOrder.nativeOrder())
+ .asFloatBuffer()
+ .put(vertices)
+ vertexBuffer?.position(0)
+
+ texCoordBuffer = ByteBuffer.allocateDirect(texCoords.size * 4)
+ .order(ByteOrder.nativeOrder())
+ .asFloatBuffer()
+ .put(texCoords)
+ texCoordBuffer?.position(0)
+ }
+
+ override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
+ GLES20.glViewport(0, 0, width, height)
+ surfaceWidth = width
+ surfaceHeight = height
+
+ // Notify host of size change (handles engine creation/resize)
+ host.onSurfaceSizeChanged(width, height)
+
+ // Create bitmap for rendering
+ renderBitmap?.recycle()
+ renderBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ }
+
+ override fun onDrawFrame(gl: GL10?) {
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
+
+ val engine = host.getDrawingEngine()
+ val bitmap = renderBitmap
+ if (engine == 0L || bitmap == null) return
+
+ // Render Skia content to bitmap
+ host.renderEngineToPixels(engine, bitmap)
+
+ // Upload bitmap to texture
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
+ GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
+
+ // Draw textured quad
+ GLES20.glUseProgram(programId)
+
+ val positionHandle = GLES20.glGetAttribLocation(programId, "aPosition")
+ val texCoordHandle = GLES20.glGetAttribLocation(programId, "aTexCoord")
+ val textureHandle = GLES20.glGetUniformLocation(programId, "uTexture")
+
+ GLES20.glEnableVertexAttribArray(positionHandle)
+ GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer)
+
+ GLES20.glEnableVertexAttribArray(texCoordHandle)
+ GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer)
+
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
+ GLES20.glUniform1i(textureHandle, 0)
+
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
+
+ GLES20.glDisableVertexAttribArray(positionHandle)
+ GLES20.glDisableVertexAttribArray(texCoordHandle)
+ }
+
+ private fun loadShader(type: Int, shaderCode: String): Int {
+ val shader = GLES20.glCreateShader(type)
+ GLES20.glShaderSource(shader, shaderCode)
+ GLES20.glCompileShader(shader)
+ return shader
+ }
+}
diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt
new file mode 100644
index 0000000..985b4a1
--- /dev/null
+++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt
@@ -0,0 +1,750 @@
+package com.mathnotes.mobileink
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.PixelFormat
+import android.opengl.GLSurfaceView
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.view.MotionEvent
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.uimanager.events.RCTEventEmitter
+
+class MobileInkCanvasView(context: Context) : GLSurfaceView(context), DrawingEngineHost {
+
+ private var drawingEngine: Long = 0
+ private val renderer: DrawingRenderer
+ private var viewWidth: Int = 0
+ private var viewHeight: Int = 0
+ private var nativeLibraryAvailable: Boolean = false
+
+ // Tool state tracking
+ private var currentToolType: String = "pen"
+ private var currentEraserMode: String = "pixel"
+ private var currentStrokeWidth: Float = 3f
+ private val holdToShapeDelayMs: Long = 300
+ private val holdToShapeHandler = Handler(Looper.getMainLooper())
+ private val holdToShapeRunnable = Runnable { showHoldToShapePreview() }
+
+ // Selection move state
+ private var isMovingSelection: Boolean = false
+ private var lastDragX: Float = 0f
+ private var lastDragY: Float = 0f
+
+ // Eraser cursor state (for pixel eraser)
+ private var eraserCursorX: Float = 0f
+ private var eraserCursorY: Float = 0f
+ private var showEraserCursor: Boolean = false
+
+ // Background type for pattern rendering (handled in C++ Skia layer)
+ private var currentBackgroundType: String = "plain"
+ private var currentPdfBackgroundUri: String? = null
+
+ // Drawing policy: "default", "anyinput", or "pencilonly"
+ // When "pencilonly", only stylus touches are processed for drawing
+ var drawingPolicy: String = "default"
+ var renderBackend: String = "ganesh"
+ var renderSuspended: Boolean = false
+ set(value) {
+ if (field == value) return
+ field = value
+ if (!value) {
+ requestInkRender(force = true)
+ }
+ }
+
+ init {
+ // Try to load native library - don't crash if it fails
+ nativeLibraryAvailable = ensureLibraryLoaded()
+
+ setEGLContextClientVersion(2)
+ setEGLConfigChooser(8, 8, 8, 8, 16, 0)
+ holder.setFormat(PixelFormat.RGBA_8888)
+
+ renderer = DrawingRenderer(this)
+ setRenderer(renderer)
+ renderMode = RENDERMODE_WHEN_DIRTY
+ }
+
+ // DrawingEngineHost interface implementation
+ override fun getDrawingEngine(): Long = drawingEngine
+
+ override fun onSurfaceSizeChanged(width: Int, height: Int) {
+ viewWidth = width
+ viewHeight = height
+
+ // Create or recreate drawing engine
+ if (drawingEngine != 0L) {
+ resizeEngine(drawingEngine, width, height)
+ } else {
+ drawingEngine = createDrawingEngine(width, height)
+ }
+
+ // Re-apply background type to engine (in case it was set before engine was created,
+ // or engine was recreated). Always apply to ensure consistency.
+ if (drawingEngine != 0L) {
+ setBackgroundType(drawingEngine, currentBackgroundType)
+ }
+
+ // Re-apply PDF background if we have one (needs to be re-rendered at new size)
+ if (!currentPdfBackgroundUri.isNullOrEmpty()) {
+ setPdfBackgroundUri(currentPdfBackgroundUri)
+ }
+ }
+
+ override fun renderEngineToPixels(engine: Long, bitmap: Bitmap) {
+ renderToPixels(engine, bitmap)
+ }
+
+ /**
+ * Set background type for pattern rendering.
+ * Patterns are rendered in the C++ Skia layer.
+ */
+ fun setBackgroundType(type: String) {
+ currentBackgroundType = type
+ queueEvent {
+ if (drawingEngine != 0L) {
+ setBackgroundType(drawingEngine, type)
+ }
+ }
+ requestInkRender()
+ }
+
+ /**
+ * Set PDF background URI for PDF page rendering.
+ * Loads the PDF, renders it to a bitmap, and passes to C++ for display.
+ */
+ fun setPdfBackgroundUri(uri: String?) {
+ currentPdfBackgroundUri = uri
+
+ if (uri.isNullOrEmpty()) {
+ // Clear PDF background
+ queueEvent {
+ if (drawingEngine != 0L) {
+ setPdfBackgroundBitmap(drawingEngine, null)
+ }
+ }
+ requestInkRender()
+ return
+ }
+
+ // Load PDF and render to bitmap on background thread
+ Thread {
+ val pdfBitmap = PdfLoader.loadAndRenderPdf(context, uri, viewWidth)
+ if (pdfBitmap != null) {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ setPdfBackgroundBitmap(drawingEngine, pdfBitmap)
+ setBackgroundType(drawingEngine, "pdf")
+ }
+ // Recycle bitmap after passing to C++ (it copies the pixels)
+ pdfBitmap.recycle()
+ }
+ requestInkRender()
+ } else {
+ android.util.Log.e("MobileInkCanvasView", "Failed to load PDF background")
+ }
+ }.start()
+ }
+
+ // Track the registered React tag to properly unregister on detach
+ private var registeredTag: Int = -1
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ // Register this view with MobileInkModule for bridgeless architecture support
+ // The 'id' is set by React Native to the view's React tag
+ if (id != -1 && id != 0) {
+ registeredTag = id
+ MobileInkModule.registerView(id, this)
+ }
+ }
+
+ // Called by React Native when the view's id (React tag) is set
+ override fun setId(id: Int) {
+ super.setId(id)
+ // Register with the new id if we haven't already and we're attached
+ if (id != -1 && id != 0 && isAttachedToWindow && registeredTag != id) {
+ // Unregister old tag if any
+ if (registeredTag != -1) {
+ MobileInkModule.unregisterView(registeredTag)
+ }
+ registeredTag = id
+ MobileInkModule.registerView(id, this)
+ }
+ }
+
+ override fun onDetachedFromWindow() {
+ cancelHoldToShapePreview()
+
+ // Unregister this view from MobileInkModule
+ if (registeredTag != -1) {
+ MobileInkModule.unregisterView(registeredTag)
+ registeredTag = -1
+ }
+
+ queueEvent {
+ if (drawingEngine != 0L) {
+ destroyDrawingEngine(drawingEngine)
+ drawingEngine = 0
+ }
+ }
+ super.onDetachedFromWindow()
+ }
+
+ private fun requestInkRender(force: Boolean = false) {
+ if (!renderSuspended || force) {
+ super.requestRender()
+ }
+ }
+
+ private fun canPreviewHoldToShape(): Boolean {
+ return currentToolType == "pen" ||
+ currentToolType == "pencil" ||
+ currentToolType == "marker" ||
+ currentToolType == "highlighter" ||
+ currentToolType == "crayon" ||
+ currentToolType == "calligraphy"
+ }
+
+ private fun scheduleHoldToShapePreview() {
+ cancelHoldToShapePreview()
+ if (!canPreviewHoldToShape()) return
+ holdToShapeHandler.postDelayed(holdToShapeRunnable, holdToShapeDelayMs)
+ }
+
+ private fun cancelHoldToShapePreview() {
+ holdToShapeHandler.removeCallbacks(holdToShapeRunnable)
+ }
+
+ private fun showHoldToShapePreview() {
+ if (!canPreviewHoldToShape() || drawingEngine == 0L) return
+
+ val timestamp = SystemClock.uptimeMillis()
+ queueEvent {
+ if (drawingEngine != 0L) {
+ updateHoldShapePreview(drawingEngine, timestamp)
+ }
+ }
+ requestInkRender()
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ if (!nativeLibraryAvailable) {
+ android.util.Log.e("MobileInkCanvasView", "onTouchEvent: native library not available")
+ return false
+ }
+ if (drawingEngine == 0L) {
+ android.util.Log.e("MobileInkCanvasView", "onTouchEvent: drawingEngine is 0")
+ return false
+ }
+
+ val toolType = event.getToolType(0)
+ val isFingerInput = toolType == MotionEvent.TOOL_TYPE_FINGER
+ val isSelectionInteraction = currentToolType == "select" || isMovingSelection
+ if (drawingPolicy == "pencilonly" && isFingerInput && !isSelectionInteraction) {
+ return false
+ }
+
+ // CRITICAL: Request parent to not intercept touch events
+ // This is essential for drawing - without it, ScrollView or other parents
+ // will intercept ACTION_MOVE events, causing only dots to appear
+ parent?.requestDisallowInterceptTouchEvent(true)
+
+ val x = event.x
+ val y = event.y
+
+ // Extract stylus data - pressure, tilt, and orientation
+ val pressure = event.pressure.coerceIn(0f, 1f)
+
+ // Get stylus tilt (altitude) - AXIS_TILT is tilt angle from perpendicular
+ // 0 = perpendicular to screen, pi/2 = parallel to screen
+ val tilt = event.getAxisValue(MotionEvent.AXIS_TILT)
+ // Convert to altitude (perpendicular = pi/2, parallel = 0)
+ val altitude = (Math.PI.toFloat() / 2f) - tilt
+
+ // Get stylus orientation (azimuth) - angle around the perpendicular axis
+ val azimuth = event.getAxisValue(MotionEvent.AXIS_ORIENTATION)
+ val isStylusInput = toolType == MotionEvent.TOOL_TYPE_STYLUS ||
+ toolType == MotionEvent.TOOL_TYPE_ERASER
+ val eventTimestamp = event.eventTime
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ // For select tool, check if tapping inside existing selection to move it
+ if (currentToolType == "select" && drawingEngine != 0L) {
+ val selectionCount = getSelectionCount(drawingEngine)
+ if (selectionCount > 0) {
+ val bounds = getSelectionBounds(drawingEngine)
+ if (bounds != null && isPointInBounds(x, y, bounds)) {
+ // Start moving the selection
+ isMovingSelection = true
+ lastDragX = x
+ lastDragY = y
+ cancelHoldToShapePreview()
+ return true
+ }
+ }
+ }
+
+ // Update eraser cursor position for pixel eraser
+ if (currentToolType == "eraser" && currentEraserMode == "pixel") {
+ eraserCursorX = x
+ eraserCursorY = y
+ showEraserCursor = true
+ }
+
+ // Queue touch event to GL thread to avoid race conditions with render thread
+ queueEvent {
+ if (drawingEngine != 0L) {
+ // Set eraser cursor in C++ engine (radius is half the stroke width)
+ if (currentToolType == "eraser" && currentEraserMode == "pixel") {
+ setEraserCursor(drawingEngine, x, y, currentStrokeWidth / 2f, true)
+ }
+ touchBegan(drawingEngine, x, y, pressure, azimuth, altitude, eventTimestamp, isStylusInput)
+ }
+ }
+ requestInkRender()
+ scheduleHoldToShapePreview()
+ sendDrawingBeginEvent(x, y)
+ }
+ MotionEvent.ACTION_MOVE -> {
+ // Handle selection move
+ if (isMovingSelection && drawingEngine != 0L) {
+ cancelHoldToShapePreview()
+ val dx = x - lastDragX
+ val dy = y - lastDragY
+ queueEvent {
+ if (drawingEngine != 0L) {
+ moveSelection(drawingEngine, dx, dy)
+ }
+ }
+ lastDragX = x
+ lastDragY = y
+ requestInkRender()
+ emitSelectionChange()
+ return true
+ }
+
+ // Update eraser cursor position (local state)
+ if (currentToolType == "eraser" && currentEraserMode == "pixel") {
+ eraserCursorX = x
+ eraserCursorY = y
+ }
+
+ // Collect historical points for batch processing
+ val historySize = event.historySize
+ val historicalPoints = mutableListOf()
+ val historicalTimestamps = mutableListOf()
+ for (i in 0 until historySize) {
+ val hx = event.getHistoricalX(i)
+ val hy = event.getHistoricalY(i)
+ val hp = event.getHistoricalPressure(i).coerceIn(0f, 1f)
+ val hTilt = event.getHistoricalAxisValue(MotionEvent.AXIS_TILT, i)
+ val hAltitude = (Math.PI.toFloat() / 2f) - hTilt
+ val hAzimuth = event.getHistoricalAxisValue(MotionEvent.AXIS_ORIENTATION, i)
+ historicalPoints.add(floatArrayOf(hx, hy, hp, hAzimuth, hAltitude))
+ historicalTimestamps.add(event.getHistoricalEventTime(i))
+ }
+
+ // Queue all touch moves to GL thread
+ queueEvent {
+ if (drawingEngine != 0L) {
+ // Update eraser cursor in C++ engine
+ if (currentToolType == "eraser" && currentEraserMode == "pixel") {
+ setEraserCursor(drawingEngine, x, y, currentStrokeWidth / 2f, true)
+ }
+ // Process historical points
+ for ((index, point) in historicalPoints.withIndex()) {
+ touchMoved(
+ drawingEngine,
+ point[0],
+ point[1],
+ point[2],
+ point[3],
+ point[4],
+ historicalTimestamps[index],
+ isStylusInput
+ )
+ }
+ // Process current point
+ touchMoved(drawingEngine, x, y, pressure, azimuth, altitude, eventTimestamp, isStylusInput)
+ }
+ }
+ requestInkRender()
+ scheduleHoldToShapePreview()
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ cancelHoldToShapePreview()
+
+ // Finalize selection move
+ if (isMovingSelection && drawingEngine != 0L) {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ finalizeMove(drawingEngine)
+ }
+ post { emitSelectionChange() }
+ }
+ isMovingSelection = false
+ requestInkRender()
+ sendEvent("onDrawingChange", Arguments.createMap())
+ return true
+ }
+
+ // Hide eraser cursor (local state)
+ showEraserCursor = false
+
+ // Queue touch end to GL thread to avoid race conditions
+ queueEvent {
+ if (drawingEngine != 0L) {
+ setEraserCursor(drawingEngine, 0f, 0f, 0f, false)
+ touchEnded(drawingEngine, eventTimestamp)
+ }
+ if (currentToolType == "select") {
+ post { emitSelectionChange() }
+ }
+ }
+ requestInkRender()
+ if (currentToolType != "select") {
+ sendEvent("onDrawingChange", Arguments.createMap())
+ }
+ }
+ }
+
+ return true
+ }
+
+ fun clear() {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ clearCanvas(drawingEngine)
+ post { emitSelectionChange() }
+ }
+ }
+ requestInkRender()
+ sendEvent("onDrawingChange", Arguments.createMap())
+ }
+
+ fun undo() {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ undoStroke(drawingEngine)
+ post { emitSelectionChange() }
+ }
+ }
+ requestInkRender()
+ sendEvent("onDrawingChange", Arguments.createMap())
+ }
+
+ fun redo() {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ redoStroke(drawingEngine)
+ post { emitSelectionChange() }
+ }
+ }
+ requestInkRender()
+ sendEvent("onDrawingChange", Arguments.createMap())
+ }
+
+ fun setTool(toolType: String, width: Float, color: Int) {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ setStrokeWidth(drawingEngine, width)
+ setStrokeColor(drawingEngine, color)
+ setTool(drawingEngine, toolType)
+ }
+ }
+ }
+
+ fun setToolWithParams(toolType: String, width: Float, color: Int, eraserMode: String?) {
+ cancelHoldToShapePreview()
+ val wasSelectionMode = currentToolType == "select"
+
+ // Update tool state (local - doesn't need queuing)
+ currentToolType = toolType
+ currentEraserMode = eraserMode ?: "pixel"
+ currentStrokeWidth = width
+
+ // Hide eraser cursor if switching away from pixel eraser
+ if (toolType != "eraser" || eraserMode != "pixel") {
+ showEraserCursor = false
+ }
+
+ // Queue engine operations to GL thread
+ queueEvent {
+ if (drawingEngine != 0L) {
+ // Clear selection when switching away from select tool
+ if (wasSelectionMode && toolType != "select") {
+ clearSelection(drawingEngine)
+ post { emitSelectionChange() }
+ }
+ setToolWithParams(drawingEngine, toolType, width, color, eraserMode ?: "")
+ }
+ }
+ if (wasSelectionMode && toolType != "select") {
+ requestInkRender()
+ }
+ }
+
+ // Helper to check if a point is inside selection bounds
+ private fun isPointInBounds(x: Float, y: Float, bounds: FloatArray): Boolean {
+ // bounds is [minX, minY, maxX, maxY]
+ return x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y <= bounds[3]
+ }
+
+ fun setDrawingBackgroundColor(color: Int) {
+ renderer.backgroundColor = color
+ requestInkRender()
+ }
+
+ // Selection operations
+ // Note: selectAt needs to return synchronously, so we can't easily queue it
+ // It only reads data, doesn't modify, so should be safe
+ fun selectAt(x: Float, y: Float): Boolean {
+ return if (drawingEngine != 0L) {
+ val result = selectStrokeAt(drawingEngine, x, y)
+ requestInkRender()
+ emitSelectionChange()
+ result
+ } else false
+ }
+
+ fun clearSelection() {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ clearSelection(drawingEngine)
+ post { emitSelectionChange() }
+ }
+ }
+ requestInkRender()
+ }
+
+ fun deleteSelection() {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ deleteSelection(drawingEngine)
+ post { emitSelectionChange() }
+ }
+ }
+ requestInkRender()
+ }
+
+ fun copySelection() {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ copySelection(drawingEngine)
+ }
+ }
+ }
+
+ fun pasteSelection(offsetX: Float, offsetY: Float) {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ pasteSelection(drawingEngine, offsetX, offsetY)
+ post { emitSelectionChange() }
+ }
+ }
+ requestInkRender()
+ }
+
+ fun moveSelection(dx: Float, dy: Float) {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ moveSelection(drawingEngine, dx, dy)
+ post { emitSelectionChange() }
+ }
+ }
+ requestInkRender()
+ }
+
+ fun finalizeMoveSelection() {
+ queueEvent {
+ if (drawingEngine != 0L) {
+ finalizeMove(drawingEngine)
+ post { emitSelectionChange() }
+ }
+ }
+ }
+
+ fun getSelectionCount(): Int {
+ return if (drawingEngine != 0L) getSelectionCount(drawingEngine) else 0
+ }
+
+ fun getSelectionBounds(): FloatArray? {
+ return if (drawingEngine != 0L) getSelectionBounds(drawingEngine) else null
+ }
+
+ // State queries
+ fun canUndo(): Boolean = drawingEngine != 0L && canUndo(drawingEngine)
+ fun canRedo(): Boolean = drawingEngine != 0L && canRedo(drawingEngine)
+ fun isEmpty(): Boolean = drawingEngine == 0L || isEmpty(drawingEngine)
+
+ // Helper for synchronous GL thread operations with timeout
+ private fun runOnGlThreadSync(timeoutMs: Long = 2000, block: () -> T?): T? {
+ val latch = java.util.concurrent.CountDownLatch(1)
+ var result: T? = null
+ queueEvent {
+ result = block()
+ latch.countDown()
+ }
+ try {
+ latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)
+ } catch (e: InterruptedException) {
+ android.util.Log.e("MobileInkCanvasView", "GL thread operation interrupted", e)
+ }
+ return result
+ }
+
+ fun serializeDrawing(): ByteArray? {
+ if (drawingEngine == 0L) return null
+ return runOnGlThreadSync(3000) {
+ if (drawingEngine != 0L) serializeDrawing(drawingEngine) else null
+ }
+ }
+
+ fun deserializeDrawing(data: ByteArray): Boolean {
+ if (drawingEngine == 0L) return false
+ val success = runOnGlThreadSync {
+ if (drawingEngine != 0L) { deserializeDrawing(drawingEngine, data); true } else false
+ } ?: false
+ requestInkRender()
+ return success
+ }
+
+ fun getBase64PngData(scale: Float): String? {
+ if (drawingEngine == 0L || viewWidth == 0 || viewHeight == 0) return null
+ return runOnGlThreadSync { exportToBase64(scale, Bitmap.CompressFormat.PNG, 100) }
+ }
+
+ fun getBase64JpegData(scale: Float, compression: Float): String? {
+ if (drawingEngine == 0L || viewWidth == 0 || viewHeight == 0) return null
+ val quality = (compression * 100).toInt().coerceIn(0, 100)
+ return runOnGlThreadSync { exportToBase64(scale, Bitmap.CompressFormat.JPEG, quality) }
+ }
+
+ private fun exportToBase64(scale: Float, format: Bitmap.CompressFormat, quality: Int): String? {
+ return try {
+ val fullBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888)
+ if (drawingEngine != 0L) renderToPixels(drawingEngine, fullBitmap)
+
+ val finalBitmap = if (scale != 1f) {
+ val w = (viewWidth * scale).toInt().coerceAtLeast(1)
+ val h = (viewHeight * scale).toInt().coerceAtLeast(1)
+ Bitmap.createScaledBitmap(fullBitmap, w, h, true).also { fullBitmap.recycle() }
+ } else fullBitmap
+
+ val stream = java.io.ByteArrayOutputStream()
+ finalBitmap.compress(format, quality, stream)
+ finalBitmap.recycle()
+
+ val mimeType = if (format == Bitmap.CompressFormat.PNG) "png" else "jpeg"
+ "data:image/$mimeType;base64," + android.util.Base64.encodeToString(stream.toByteArray(), android.util.Base64.NO_WRAP)
+ } catch (e: Exception) {
+ android.util.Log.e("MobileInkCanvasView", "Export error", e)
+ null
+ }
+ }
+
+ private fun sendEvent(eventName: String, params: com.facebook.react.bridge.WritableMap) {
+ val reactContext = context as ReactContext
+ reactContext
+ .getJSModule(RCTEventEmitter::class.java)
+ .receiveEvent(id, eventName, params)
+ }
+
+ private fun sendDrawingBeginEvent(x: Float, y: Float) {
+ val payload = Arguments.createMap()
+ payload.putDouble("x", x.toDouble())
+ payload.putDouble("y", y.toDouble())
+ sendEvent("onDrawingBegin", payload)
+ }
+
+ private fun emitSelectionChange() {
+ val payload = Arguments.createMap()
+ val bounds = getSelectionBounds()
+ val count = getSelectionCount()
+ payload.putInt("count", count)
+ if (count > 0 && bounds != null && bounds.size == 4) {
+ val boundsMap = Arguments.createMap()
+ boundsMap.putDouble("x", bounds[0].toDouble())
+ boundsMap.putDouble("y", bounds[1].toDouble())
+ boundsMap.putDouble("width", (bounds[2] - bounds[0]).toDouble())
+ boundsMap.putDouble("height", (bounds[3] - bounds[1]).toDouble())
+ payload.putMap("bounds", boundsMap)
+ } else {
+ payload.putNull("bounds")
+ }
+ sendEvent("onSelectionChange", payload)
+ }
+
+ // Native method declarations
+ private external fun createDrawingEngine(width: Int, height: Int): Long
+ private external fun destroyDrawingEngine(engine: Long)
+ private external fun resizeEngine(engine: Long, width: Int, height: Int)
+
+ // Touch handling with full stylus support
+ private external fun touchBegan(engine: Long, x: Float, y: Float, pressure: Float, azimuth: Float, altitude: Float, timestamp: Long, isStylusInput: Boolean)
+ private external fun touchMoved(engine: Long, x: Float, y: Float, pressure: Float, azimuth: Float, altitude: Float, timestamp: Long, isStylusInput: Boolean)
+ private external fun touchEnded(engine: Long, timestamp: Long)
+ private external fun updateHoldShapePreview(engine: Long, timestamp: Long): Boolean
+
+ // Canvas operations
+ private external fun clearCanvas(engine: Long)
+ private external fun undoStroke(engine: Long)
+ private external fun redoStroke(engine: Long)
+
+ // Tool settings
+ private external fun setStrokeColor(engine: Long, color: Int)
+ private external fun setStrokeWidth(engine: Long, width: Float)
+ private external fun setTool(engine: Long, toolType: String)
+ private external fun setToolWithParams(engine: Long, toolType: String, width: Float, color: Int, eraserMode: String)
+ private external fun setEraserCursor(engine: Long, x: Float, y: Float, radius: Float, visible: Boolean)
+ private external fun setBackgroundType(engine: Long, backgroundType: String)
+ private external fun setPdfBackgroundBitmap(engine: Long, bitmap: Bitmap?)
+
+ // Selection operations
+ private external fun selectStrokeAt(engine: Long, x: Float, y: Float): Boolean
+ private external fun selectShapeStrokeAt(engine: Long, x: Float, y: Float): Boolean
+ private external fun clearSelection(engine: Long)
+ private external fun deleteSelection(engine: Long)
+ private external fun copySelection(engine: Long)
+ private external fun pasteSelection(engine: Long, offsetX: Float, offsetY: Float)
+ private external fun moveSelection(engine: Long, dx: Float, dy: Float)
+ private external fun finalizeMove(engine: Long)
+ private external fun getSelectionCount(engine: Long): Int
+ private external fun getSelectionBounds(engine: Long): FloatArray?
+
+ // State queries
+ private external fun canUndo(engine: Long): Boolean
+ private external fun canRedo(engine: Long): Boolean
+ private external fun isEmpty(engine: Long): Boolean
+
+ // Serialization
+ private external fun serializeDrawing(engine: Long): ByteArray?
+ private external fun deserializeDrawing(engine: Long, data: ByteArray)
+
+ // Rendering
+ private external fun renderToPixels(engine: Long, bitmap: Bitmap)
+ private external fun renderToByteArray(engine: Long, pixels: ByteArray, width: Int, height: Int)
+
+ companion object {
+ private var libraryLoaded = false
+
+ @Synchronized
+ fun ensureLibraryLoaded(): Boolean {
+ if (!libraryLoaded) {
+ try {
+ System.loadLibrary("mobileink")
+ libraryLoaded = true
+ } catch (e: UnsatisfiedLinkError) {
+ android.util.Log.e("MobileInkCanvasView", "Failed to load mobileink library: ${e.message}")
+ return false
+ }
+ }
+ return libraryLoaded
+ }
+ }
+}
diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt
new file mode 100644
index 0000000..90089d6
--- /dev/null
+++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt
@@ -0,0 +1,159 @@
+package com.mathnotes.mobileink
+
+import android.graphics.Color
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReadableArray
+import com.facebook.react.uimanager.SimpleViewManager
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.annotations.ReactProp
+
+/**
+ * MobileInkCanvasViewManager - manages the MobileInkCanvasView which handles drawing.
+ * Background patterns are rendered in the C++ Skia layer, not as a separate Android View.
+ * This ensures eraser works correctly and view registration is proper.
+ */
+class MobileInkCanvasViewManager(private val reactContext: ReactApplicationContext) :
+ SimpleViewManager() {
+
+ override fun getName() = "MobileInkCanvasView"
+
+ override fun createViewInstance(reactContext: ThemedReactContext): MobileInkCanvasView {
+ return MobileInkCanvasView(reactContext)
+ }
+
+ override fun getExportedCustomBubblingEventTypeConstants(): Map {
+ return mapOf(
+ "onDrawingChange" to mapOf(
+ "phasedRegistrationNames" to mapOf("bubbled" to "onDrawingChange")
+ ),
+ "onDrawingBegin" to mapOf(
+ "phasedRegistrationNames" to mapOf("bubbled" to "onDrawingBegin")
+ ),
+ "onSelectionChange" to mapOf(
+ "phasedRegistrationNames" to mapOf("bubbled" to "onSelectionChange")
+ )
+ )
+ }
+
+ @ReactProp(name = "backgroundColor", customType = "Color")
+ fun setDrawingBackgroundColor(view: MobileInkCanvasView, color: Int) {
+ view.setDrawingBackgroundColor(color)
+ }
+
+ @ReactProp(name = "backgroundType")
+ fun setBackgroundType(view: MobileInkCanvasView, type: String?) {
+ view.setBackgroundType(type ?: "plain")
+ }
+
+ @ReactProp(name = "pdfBackgroundUri")
+ fun setPdfBackgroundUri(view: MobileInkCanvasView, uri: String?) {
+ view.setPdfBackgroundUri(uri)
+ }
+
+ @ReactProp(name = "drawingPolicy")
+ fun setDrawingPolicy(view: MobileInkCanvasView, policy: String?) {
+ view.drawingPolicy = policy ?: "default"
+ }
+
+ @ReactProp(name = "renderSuspended")
+ fun setRenderSuspended(view: MobileInkCanvasView, suspended: Boolean) {
+ view.renderSuspended = suspended
+ }
+
+ @ReactProp(name = "renderBackend")
+ fun setRenderBackend(view: MobileInkCanvasView, backend: String?) {
+ view.renderBackend = backend ?: "ganesh"
+ }
+
+ override fun receiveCommand(
+ root: MobileInkCanvasView,
+ commandId: String,
+ args: ReadableArray?
+ ) {
+ when (commandId) {
+ "clear" -> root.clear()
+ "undo" -> root.undo()
+ "redo" -> root.redo()
+ "setTool" -> {
+ if (args != null && args.size() >= 3) {
+ val toolType = args.getString(0) ?: "pen"
+ val width = args.getDouble(1).toFloat()
+ val colorHex = args.getString(2) ?: "#000000"
+ val color = Color.parseColor(colorHex)
+ root.setTool(toolType, width, color)
+ }
+ }
+ "setToolWithParams" -> {
+ if (args != null && args.size() >= 3) {
+ val toolType = args.getString(0) ?: "pen"
+ val width = args.getDouble(1).toFloat()
+ val colorHex = args.getString(2) ?: "#000000"
+ val color = Color.parseColor(colorHex)
+ val eraserMode = if (args.size() > 3) args.getString(3) else null
+ root.setToolWithParams(toolType, width, color, eraserMode)
+ }
+ }
+ "selectAt" -> {
+ if (args != null && args.size() >= 2) {
+ val x = args.getDouble(0).toFloat()
+ val y = args.getDouble(1).toFloat()
+ root.selectAt(x, y)
+ }
+ }
+ "clearSelection" -> root.clearSelection()
+ "deleteSelection" -> root.deleteSelection()
+ "copySelection" -> root.copySelection()
+ // Aliases used by JS side
+ "performCopy" -> root.copySelection()
+ "performPaste" -> root.pasteSelection(20f, 20f) // Default offset for paste
+ "performDelete" -> root.deleteSelection()
+ "pasteSelection" -> {
+ if (args != null && args.size() >= 2) {
+ val offsetX = args.getDouble(0).toFloat()
+ val offsetY = args.getDouble(1).toFloat()
+ root.pasteSelection(offsetX, offsetY)
+ }
+ }
+ "moveSelection" -> {
+ if (args != null && args.size() >= 2) {
+ val dx = args.getDouble(0).toFloat()
+ val dy = args.getDouble(1).toFloat()
+ root.moveSelection(dx, dy)
+ }
+ }
+ "finalizeMove" -> root.finalizeMoveSelection()
+ "deserializeDrawing" -> {
+ // Deserialize from JSON string (format: {"pages":{"0":""}})
+ // Match iOS behavior: clear canvas when data is empty/missing
+ if (args != null && args.size() >= 1) {
+ val jsonString = args.getString(0)
+ if (jsonString != null) {
+ try {
+ // Parse JSON to extract base64 from pages.0
+ val json = org.json.JSONObject(jsonString)
+ val pages = json.optJSONObject("pages")
+ val base64 = pages?.optString("0")
+ if (!base64.isNullOrEmpty()) {
+ val data = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
+ root.deserializeDrawing(data)
+ } else {
+ // Match iOS: clear canvas when no data for page 0
+ root.clear()
+ }
+ } catch (e: Exception) {
+ // Clear on parse error to prevent stale data (match iOS behavior)
+ root.clear()
+ android.util.Log.e("MobileInkCanvasViewManager", "Failed to deserialize drawing", e)
+ }
+ } else {
+ // Clear when jsonString is null
+ root.clear()
+ }
+ } else {
+ // Clear when no args provided
+ root.clear()
+ }
+ }
+ }
+ }
+}
diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkModule.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkModule.kt
new file mode 100644
index 0000000..9a5ac86
--- /dev/null
+++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkModule.kt
@@ -0,0 +1,512 @@
+package com.mathnotes.mobileink
+
+import android.net.Uri
+import android.graphics.Bitmap
+import android.util.Base64
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableArray
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.module.annotations.ReactModule
+import org.json.JSONObject
+import java.io.File
+import java.lang.ref.WeakReference
+import java.util.concurrent.Executors
+
+/**
+ * MobileInkModule provides callback-based APIs for the drawing canvas.
+ * Used for operations that need to return data (like serialization, state queries).
+ */
+@ReactModule(name = MobileInkModule.NAME)
+class MobileInkModule(private val reactContext: ReactApplicationContext) :
+ ReactContextBaseJavaModule(reactContext) {
+
+ companion object {
+ const val NAME = "MobileInkModule"
+
+ // Thread pool for background processing
+ private val executor = Executors.newSingleThreadExecutor()
+
+ // Static registry of MobileInkCanvasView instances by their React tag
+ // Uses WeakReference to avoid memory leaks when views are unmounted
+ private val viewRegistry = mutableMapOf>()
+
+ /**
+ * Register a view when it's created (called from MobileInkCanvasView)
+ */
+ fun registerView(tag: Int, view: MobileInkCanvasView) {
+ viewRegistry[tag] = WeakReference(view)
+ }
+
+ /**
+ * Unregister a view when it's destroyed (called from MobileInkCanvasView)
+ */
+ fun unregisterView(tag: Int) {
+ viewRegistry.remove(tag)
+ }
+
+ /**
+ * Get a view by its React tag
+ */
+ fun getView(tag: Int): MobileInkCanvasView? {
+ return viewRegistry[tag]?.get()
+ }
+
+ // Native method for batch export - static because it creates its own temp engine
+ @JvmStatic
+ private external fun nativeBatchExportPages(
+ pagesDataArray: Array,
+ backgroundTypes: Array,
+ pdfBackgrounds: Array,
+ pageIndices: IntArray,
+ width: Int,
+ height: Int,
+ scale: Float
+ ): Array
+ }
+
+ override fun getName() = NAME
+
+ private fun findDrawingView(viewTag: Int): MobileInkCanvasView? {
+ return getView(viewTag)
+ }
+
+ private fun fileFromBridgePath(filePath: String): File {
+ if (filePath.startsWith("file://")) {
+ val uriPath = Uri.parse(filePath).path
+ if (!uriPath.isNullOrEmpty()) {
+ return File(uriPath)
+ }
+ return File(filePath.removePrefix("file://"))
+ }
+ return File(filePath)
+ }
+
+ @ReactMethod
+ fun getBase64Data(viewTag: Int, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ if (view != null) {
+ val data = view.serializeDrawing()
+ if (data != null && data.isNotEmpty()) {
+ val base64 = Base64.encodeToString(data, Base64.NO_WRAP)
+ // Wrap in JSON format like iOS: {"pages":{"0":""}}
+ val json = """{"pages":{"0":"$base64"}}"""
+ promise.resolve(json)
+ } else {
+ // 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")
+ 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)
+ }
+ }
+ }
+
+ @ReactMethod
+ fun canUndo(viewTag: Int, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ promise.resolve(view?.canUndo() ?: false)
+ } catch (e: Exception) {
+ promise.reject("ERROR", e.message)
+ }
+ }
+ }
+
+ @ReactMethod
+ fun canRedo(viewTag: Int, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ promise.resolve(view?.canRedo() ?: false)
+ } catch (e: Exception) {
+ promise.reject("ERROR", e.message)
+ }
+ }
+ }
+
+ @ReactMethod
+ fun isEmpty(viewTag: Int, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ promise.resolve(view?.isEmpty() ?: true)
+ } catch (e: Exception) {
+ promise.reject("ERROR", e.message)
+ }
+ }
+ }
+
+ @ReactMethod
+ fun getSelectionCount(viewTag: Int, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ promise.resolve(view?.getSelectionCount() ?: 0)
+ } catch (e: Exception) {
+ promise.reject("ERROR", e.message)
+ }
+ }
+ }
+
+ @ReactMethod
+ fun getSelectionBounds(viewTag: Int, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ val bounds = view?.getSelectionBounds()
+ if (bounds != null && bounds.size == 4) {
+ val result = Arguments.createMap()
+ result.putDouble("minX", bounds[0].toDouble())
+ result.putDouble("minY", bounds[1].toDouble())
+ result.putDouble("maxX", bounds[2].toDouble())
+ result.putDouble("maxY", bounds[3].toDouble())
+ promise.resolve(result)
+ } else {
+ promise.resolve(null)
+ }
+ } catch (e: Exception) {
+ promise.reject("ERROR", e.message)
+ }
+ }
+ }
+
+ @ReactMethod
+ fun getBase64PngData(viewTag: Int, scale: Double, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ if (view != null) {
+ val data = view.getBase64PngData(scale.toFloat())
+ if (data != null) {
+ promise.resolve(data)
+ } else {
+ promise.resolve("")
+ }
+ } else {
+ android.util.Log.w("MobileInkModule", "getBase64PngData: view not found for tag $viewTag")
+ promise.resolve("")
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("MobileInkModule", "getBase64PngData: exception", e)
+ promise.resolve("")
+ }
+ }
+ }
+
+ @ReactMethod
+ fun getBase64JpegData(viewTag: Int, scale: Double, compression: Double, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ if (view != null) {
+ val data = view.getBase64JpegData(scale.toFloat(), compression.toFloat())
+ if (data != null) {
+ promise.resolve(data)
+ } else {
+ promise.resolve("")
+ }
+ } else {
+ android.util.Log.w("MobileInkModule", "getBase64JpegData: view not found for tag $viewTag")
+ promise.resolve("")
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("MobileInkModule", "getBase64JpegData: exception", e)
+ promise.resolve("")
+ }
+ }
+ }
+
+ /**
+ * Load base64 drawing data into the canvas.
+ * Returns a Promise that resolves to true when loading is complete.
+ * This ensures the caller can wait for the load to finish before proceeding.
+ */
+ @ReactMethod
+ fun loadBase64Data(viewTag: Int, jsonString: String, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ if (view == null) {
+ android.util.Log.w("MobileInkModule", "loadBase64Data: view not found for tag $viewTag")
+ promise.resolve(false)
+ return@runOnUiQueueThread
+ }
+
+ // Parse JSON format: {"pages":{"0":""}}
+ val json = JSONObject(jsonString)
+ val pages = json.optJSONObject("pages")
+ if (pages == null) {
+ view.clear()
+ promise.resolve(true)
+ return@runOnUiQueueThread
+ }
+
+ // Get page 0 data (current page)
+ val base64 = pages.optString("0", "")
+ if (base64.isEmpty()) {
+ view.clear()
+ promise.resolve(true)
+ return@runOnUiQueueThread
+ }
+
+ // Decode and load
+ val data = Base64.decode(base64, Base64.DEFAULT)
+
+ // deserializeDrawing is now synchronous (waits for GL thread)
+ val success = view.deserializeDrawing(data)
+ promise.resolve(success)
+ } catch (e: Exception) {
+ android.util.Log.e("MobileInkModule", "loadBase64Data: exception", e)
+ promise.resolve(false)
+ }
+ }
+ }
+
+ /**
+ * Native-side persistence: serialize the engine's current state to base64 JSON
+ * and write directly to disk. The body never crosses the JS<->native bridge --
+ * a major drawing-time win on heavy notebooks where the body is multi-MB.
+ *
+ * Atomicity: write goes to {path}.tmp first, then rename to {path}. POSIX
+ * rename is atomic on the same filesystem so a partial write under memory
+ * pressure can never poison the canonical body file.
+ */
+ @ReactMethod
+ fun persistEngineToFile(viewTag: Int, filePath: String, promise: Promise) {
+ reactContext.runOnUiQueueThread {
+ val view = findDrawingView(viewTag)
+ if (view == null) {
+ android.util.Log.w("MobileInkModule", "persistEngineToFile: view not found for tag $viewTag")
+ promise.reject("VIEW_NOT_FOUND", "MobileInkCanvasView not found")
+ 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
+ }
+
+ executor.execute {
+ try {
+ val body = if (data != null && data.isNotEmpty()) {
+ val base64 = Base64.encodeToString(data, Base64.NO_WRAP)
+ """{"pages":{"0":"$base64"}}"""
+ } else {
+ """{"pages":{}}"""
+ }
+
+ val target = fileFromBridgePath(filePath)
+ target.parentFile?.let { parent ->
+ if (!parent.exists()) parent.mkdirs()
+ }
+ val tmp = File("${target.absolutePath}.tmp")
+ // Best-effort: drop a leftover .tmp from a previous failed write.
+ if (tmp.exists()) tmp.delete()
+ tmp.writeText(body)
+ val renamed = tmp.renameTo(target)
+ if (!renamed) {
+ // Rename failed -- fall back to a regular write into target,
+ // then drop the .tmp. Loses atomicity but at least the body
+ // is durable on disk.
+ target.writeText(body)
+ if (tmp.exists()) tmp.delete()
+ }
+ promise.resolve(true)
+ } catch (e: Exception) {
+ android.util.Log.e("MobileInkModule", "persistEngineToFile: write exception", e)
+ promise.reject("WRITE_FAILED", e.message ?: "write failed", e)
+ }
+ }
+ }
+ }
+
+ /**
+ * Inverse of persistEngineToFile: read the body file directly and feed it
+ * into the engine without the bytes ever crossing the bridge as a JS string.
+ *
+ * Resolves true on successful load, false if the file is missing/empty.
+ * Rejects only on actual deserialization errors.
+ */
+ @ReactMethod
+ fun loadEngineFromFile(viewTag: Int, filePath: String, promise: Promise) {
+ executor.execute {
+ val target = fileFromBridgePath(filePath)
+ if (!target.exists() || target.length() == 0L) {
+ promise.resolve(false)
+ return@execute
+ }
+
+ val body: String = try {
+ target.readText()
+ } catch (e: Exception) {
+ android.util.Log.e("MobileInkModule", "loadEngineFromFile: read exception", e)
+ promise.resolve(false)
+ return@execute
+ }
+
+ reactContext.runOnUiQueueThread {
+ try {
+ val view = findDrawingView(viewTag)
+ if (view == null) {
+ promise.reject("VIEW_NOT_FOUND", "MobileInkCanvasView not found")
+ return@runOnUiQueueThread
+ }
+
+ val json = JSONObject(body)
+ val pages = json.optJSONObject("pages")
+ if (pages == null) {
+ view.clear()
+ promise.resolve(true)
+ return@runOnUiQueueThread
+ }
+
+ val base64 = pages.optString("0", "")
+ if (base64.isEmpty()) {
+ view.clear()
+ promise.resolve(true)
+ return@runOnUiQueueThread
+ }
+
+ val data = Base64.decode(base64, Base64.DEFAULT)
+ val success = view.deserializeDrawing(data)
+ promise.resolve(success)
+ } catch (e: Exception) {
+ android.util.Log.e("MobileInkModule", "loadEngineFromFile: deserialize exception", e)
+ promise.reject("LOAD_DATA_ERROR", e.message ?: "deserialize failed", e)
+ }
+ }
+ }
+ }
+
+ /**
+ * Batch export multiple pages to PNG images.
+ * This is much faster than exporting pages one by one because it:
+ * 1. Creates a single Skia engine and surface (reused for all pages)
+ * 2. Doesn't switch visible pages (no UI updates)
+ * 3. Processes all pages in a single native call
+ *
+ * @param pagesDataArray Array of JSON strings with format {"pages":{"0":""}}
+ * @param backgroundTypes Array of background type strings ("plain", "lined", "grid", "pdf")
+ * @param width Canvas width in pixels
+ * @param height Canvas height in pixels
+ * @param scale Export scale factor (e.g., 2.0 for retina)
+ * @param pdfBackgroundUri Optional PDF file URI for PDF backgrounds.
+ * @param pageIndices Original notebook page indices for page-aware background export.
+ * @param promise Resolves to array of base64 PNG data URIs
+ */
+ @ReactMethod
+ fun batchExportPages(
+ pagesDataArray: ReadableArray,
+ backgroundTypes: ReadableArray,
+ width: Int,
+ height: Int,
+ scale: Double,
+ pdfBackgroundUri: String,
+ pageIndices: ReadableArray,
+ promise: Promise
+ ) {
+ // Ensure native library is loaded
+ if (!MobileInkCanvasView.ensureLibraryLoaded()) {
+ promise.reject("LIBRARY_NOT_LOADED", "Failed to load native drawing library")
+ return
+ }
+
+ // Process on background thread to avoid blocking UI
+ executor.execute {
+ try {
+ val numPages = pagesDataArray.size()
+ if (numPages == 0) {
+ promise.resolve(Arguments.createArray())
+ return@execute
+ }
+
+ // Decode page payloads before JNI so valid JSON formatting differences
+ // do not leak into the C++ boundary.
+ val pagesData = Array(numPages) { i ->
+ decodePagePayload(pagesDataArray.getString(i) ?: "")
+ }
+ val bgTypes = Array(numPages) { i ->
+ if (i < backgroundTypes.size()) backgroundTypes.getString(i) ?: "plain" else "plain"
+ }
+ val resolvedPageIndices = IntArray(numPages) { i ->
+ if (i < pageIndices.size()) pageIndices.getInt(i) else i
+ }
+ val pdfBackgrounds = Array(numPages) { i ->
+ if (bgTypes[i] != "pdf" || pdfBackgroundUri.isEmpty()) {
+ null
+ } else {
+ val pageAwareUri = pdfUriForPage(pdfBackgroundUri, resolvedPageIndices[i], numPages)
+ PdfLoader.loadAndRenderPdf(reactApplicationContext, pageAwareUri, width)
+ }
+ }
+
+ // Call native batch export
+ val results = nativeBatchExportPages(
+ pagesData,
+ bgTypes,
+ pdfBackgrounds,
+ resolvedPageIndices,
+ width,
+ height,
+ scale.toFloat()
+ )
+
+ pdfBackgrounds.forEach { it?.recycle() }
+
+ // Convert to WritableArray
+ val resultArray = Arguments.createArray()
+ for (result in results) {
+ resultArray.pushString(result ?: "")
+ }
+
+ promise.resolve(resultArray)
+ } catch (e: Exception) {
+ android.util.Log.e("MobileInkModule", "batchExportPages: exception", e)
+ promise.reject("BATCH_EXPORT_ERROR", e.message, e)
+ }
+ }
+ }
+
+ private fun pdfUriForPage(pdfBackgroundUri: String, pageIndex: Int, exportedPageCount: Int): String {
+ if (exportedPageCount == 1 && pdfBackgroundUri.contains("#page=")) {
+ return pdfBackgroundUri
+ }
+ val cleanUri = pdfBackgroundUri.substringBefore("#")
+ return "$cleanUri#page=${pageIndex + 1}"
+ }
+
+ private fun decodePagePayload(pageJson: String): ByteArray? {
+ return try {
+ val json = JSONObject(pageJson)
+ val pages = json.optJSONObject("pages") ?: return null
+ val base64 = pages.optString("0", "")
+ if (base64.isEmpty()) {
+ null
+ } else {
+ Base64.decode(base64, Base64.DEFAULT).takeIf { it.isNotEmpty() }
+ }
+ } catch (e: Exception) {
+ android.util.Log.w("MobileInkModule", "decodePagePayload: treating invalid page payload as blank")
+ null
+ }
+ }
+}
diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkPackage.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkPackage.kt
new file mode 100644
index 0000000..c5aae1e
--- /dev/null
+++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkPackage.kt
@@ -0,0 +1,51 @@
+package com.mathnotes.mobileink
+
+import com.facebook.react.TurboReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.module.annotations.ReactModuleList
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
+import com.facebook.react.uimanager.ViewManager
+
+/**
+ * TurboReactPackage for the native drawing module.
+ *
+ * Using TurboReactPackage instead of ReactPackage is critical for React Native 0.81+
+ * with new architecture enabled. It provides module metadata via getReactModuleInfoProvider()
+ * which allows RN to register modules without loading native libraries during startup scan.
+ */
+@ReactModuleList(nativeModules = [MobileInkModule::class])
+class MobileInkPackage : TurboReactPackage() {
+
+ override fun createNativeModules(reactContext: ReactApplicationContext): List {
+ return listOf(MobileInkModule(reactContext))
+ }
+
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
+ return when (name) {
+ MobileInkModule.NAME -> MobileInkModule(reactContext)
+ else -> null
+ }
+ }
+
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
+ return ReactModuleInfoProvider {
+ mapOf(
+ MobileInkModule.NAME to ReactModuleInfo(
+ MobileInkModule.NAME,
+ MobileInkModule::class.java.name,
+ false, // canOverrideExistingModule
+ false, // needsEagerInit
+ false, // hasConstants - we have no getConstants()
+ false, // isCxxModule - this is Kotlin, not C++
+ false // isTurboModule - false to use legacy NativeModules bridge
+ )
+ )
+ }
+ }
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> {
+ return listOf(MobileInkCanvasViewManager(reactContext))
+ }
+}
diff --git a/android/src/main/java/com/mathnotes/mobileink/PdfLoader.kt b/android/src/main/java/com/mathnotes/mobileink/PdfLoader.kt
new file mode 100644
index 0000000..022d448
--- /dev/null
+++ b/android/src/main/java/com/mathnotes/mobileink/PdfLoader.kt
@@ -0,0 +1,199 @@
+package com.mathnotes.mobileink
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.pdf.PdfRenderer
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import android.util.Base64
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Utility class for loading PDF files from various sources and rendering them to bitmaps.
+ * Supports: content:// (document picker), file://, https://, http://,
+ * absolute paths, and data:application/pdf;base64 URIs.
+ */
+object PdfLoader {
+
+ /**
+ * Load a PDF and render it to a bitmap.
+ * @param context Android context for content resolver access
+ * @param uri PDF URI (supports multiple formats)
+ * @param viewWidth Target width for rendering (uses PDF native width if 0)
+ * @return Rendered bitmap or null on failure
+ */
+ fun loadAndRenderPdf(context: Context, uri: String, viewWidth: Int): Bitmap? {
+ // Parse page number from URI fragment (#page=N, 1-indexed)
+ var pageNumber = 0
+ var cleanUri = uri
+
+ val hashIndex = uri.indexOf('#')
+ if (hashIndex != -1) {
+ val fragment = uri.substring(hashIndex + 1)
+ if (fragment.startsWith("page=")) {
+ pageNumber = (fragment.substringAfter("page=").toIntOrNull() ?: 1) - 1
+ }
+ cleanUri = uri.substring(0, hashIndex)
+ }
+
+ return try {
+ when {
+ // Handle base64 data URI
+ cleanUri.startsWith("data:application/pdf;base64,") -> {
+ loadPdfFromBase64(context, cleanUri, pageNumber, viewWidth)
+ }
+ // Handle content:// URI (from document picker - Google Drive, Downloads, etc.)
+ cleanUri.startsWith("content://") -> {
+ loadPdfFromContentUri(context, cleanUri, pageNumber, viewWidth)
+ }
+ // Handle https:// and http:// URLs
+ cleanUri.startsWith("https://") || cleanUri.startsWith("http://") -> {
+ loadPdfFromUrl(context, cleanUri, pageNumber, viewWidth)
+ }
+ // Handle file:// URI
+ cleanUri.startsWith("file://") -> {
+ loadPdfFromFile(File(cleanUri.removePrefix("file://")), pageNumber, viewWidth)
+ }
+ // Handle absolute path
+ cleanUri.startsWith("/") -> {
+ loadPdfFromFile(File(cleanUri), pageNumber, viewWidth)
+ }
+ else -> {
+ android.util.Log.e("PdfLoader", "Unsupported PDF URI format")
+ null
+ }
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("PdfLoader", "Error loading PDF", e)
+ null
+ }
+ }
+
+ /**
+ * Load PDF from a base64 data URI by writing to temp file.
+ */
+ private fun loadPdfFromBase64(context: Context, dataUri: String, pageNumber: Int, viewWidth: Int): Bitmap? {
+ val base64Data = dataUri.removePrefix("data:application/pdf;base64,")
+ val pdfBytes = Base64.decode(base64Data, Base64.DEFAULT)
+
+ // PdfRenderer requires a file, so write to temp file
+ val tempFile = File.createTempFile("pdf_bg_", ".pdf", context.cacheDir)
+ try {
+ tempFile.writeBytes(pdfBytes)
+ return loadPdfFromFile(tempFile, pageNumber, viewWidth)
+ } finally {
+ tempFile.delete()
+ }
+ }
+
+ /**
+ * Load PDF from a file and render the specified page to a bitmap.
+ */
+ private fun loadPdfFromFile(file: File, pageNumber: Int, viewWidth: Int): Bitmap? {
+ if (!file.exists()) {
+ android.util.Log.e("PdfLoader", "PDF file not found")
+ return null
+ }
+
+ val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
+ val renderer = PdfRenderer(pfd)
+
+ try {
+ // Clamp page number to valid range
+ val validPageNumber = pageNumber.coerceIn(0, renderer.pageCount - 1)
+ val page = renderer.openPage(validPageNumber)
+
+ try {
+ // Calculate render dimensions based on view size
+ val targetWidth = if (viewWidth > 0) viewWidth else page.width
+ val scale = targetWidth.toFloat() / page.width
+ val scaledHeight = (page.height * scale).toInt()
+
+ // Create bitmap with white background
+ val bitmap = Bitmap.createBitmap(targetWidth, scaledHeight, Bitmap.Config.ARGB_8888)
+ bitmap.eraseColor(android.graphics.Color.WHITE)
+
+ // Render PDF page
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
+
+ return bitmap
+ } finally {
+ page.close()
+ }
+ } finally {
+ renderer.close()
+ pfd.close()
+ }
+ }
+
+ /**
+ * Load PDF from a content:// URI (from Android document picker).
+ */
+ private fun loadPdfFromContentUri(context: Context, uriString: String, pageNumber: Int, viewWidth: Int): Bitmap? {
+ val uri = Uri.parse(uriString)
+
+ val pfd = context.contentResolver.openFileDescriptor(uri, "r")
+ ?: run {
+ android.util.Log.e("PdfLoader", "Failed to open content URI")
+ return null
+ }
+
+ val renderer = PdfRenderer(pfd)
+
+ try {
+ val validPageNumber = pageNumber.coerceIn(0, renderer.pageCount - 1)
+ val page = renderer.openPage(validPageNumber)
+
+ try {
+ val targetWidth = if (viewWidth > 0) viewWidth else page.width
+ val scale = targetWidth.toFloat() / page.width
+ val scaledHeight = (page.height * scale).toInt()
+
+ val bitmap = Bitmap.createBitmap(targetWidth, scaledHeight, Bitmap.Config.ARGB_8888)
+ bitmap.eraseColor(android.graphics.Color.WHITE)
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
+
+ return bitmap
+ } finally {
+ page.close()
+ }
+ } finally {
+ renderer.close()
+ pfd.close()
+ }
+ }
+
+ /**
+ * Load PDF from an https:// or http:// URL by downloading to temp file.
+ */
+ private fun loadPdfFromUrl(context: Context, urlString: String, pageNumber: Int, viewWidth: Int): Bitmap? {
+ val tempFile = File.createTempFile("pdf_download_", ".pdf", context.cacheDir)
+ try {
+ val connection = URL(urlString).openConnection() as HttpURLConnection
+ connection.connectTimeout = 30000
+ connection.readTimeout = 30000
+ connection.instanceFollowRedirects = true
+
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) {
+ android.util.Log.e("PdfLoader",
+ "Failed to download PDF: HTTP ${connection.responseCode}")
+ return null
+ }
+
+ connection.inputStream.use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+
+ return loadPdfFromFile(tempFile, pageNumber, viewWidth)
+ } catch (e: Exception) {
+ android.util.Log.e("PdfLoader", "Error downloading PDF", e)
+ return null
+ } finally {
+ tempFile.delete()
+ }
+ }
+}
diff --git a/docs/api.md b/docs/api.md
index 3fe56a5..618da2e 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -32,7 +32,7 @@ High-level continuous notebook component.
- `onPagesChange(pages)`: called when page growth or trimming changes the page array.
- `onSelectionChange(pageId, count, bounds)`: native selection event by page.
- `onMotionStateChange(isMoving)`: viewport gesture/momentum state.
-- `onPencilDoubleTap(event)`: Apple Pencil double-tap callback.
+- `onPencilDoubleTap(event)`: Apple Pencil double-tap callback on iOS.
### Ref
diff --git a/docs/architecture.md b/docs/architecture.md
index 2a25009..f3b36f7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -24,12 +24,14 @@ flowchart TD
- `ContinuousEnginePool` keeps a fixed number of native canvases mounted and reassigns them to pages.
- `InfiniteInkCanvas` composes the viewport and pool into a generic continuous notebook shell.
-### iOS native bridge
+### Native bridges
-- `MobileInkCanvasView` is the Metal-backed native drawing surface.
-- `MobileInkCanvasViewManager` exposes React Native props and commands.
-- `MobileInkBridge` exposes batch export, notebook parsing, and persistence helpers that are not tied to a single view.
-- `MobileInkBackgroundView` renders generic page backgrounds.
+- iOS `MobileInkCanvasView` is the Metal-backed native drawing surface.
+- Android `MobileInkCanvasView` is a `GLSurfaceView` that renders the shared C++ engine into an OpenGL texture.
+- `MobileInkCanvasViewManager` exposes React Native props and commands on both platforms.
+- `MobileInkBridge` exposes iOS helpers that are not tied to a single view, including notebook parsing and continuous-window compose/decompose.
+- `MobileInkModule` exposes Android promise-based drawing persistence and batch export helpers.
+- `MobileInkBackgroundView` renders generic page backgrounds on iOS; Android backgrounds are rendered by the shared Skia engine inside the canvas.
### C++ Skia engine
@@ -57,4 +59,4 @@ Consumers own storage and decide how serialized notebook payloads move through t
## Native Memory Lifecycle
-The pool reuses native views for page changes. Heavy native state is released only on final unmount through `releaseEngine`, which prevents scroll-driven MTKView churn and keeps allocations flat during repeated page crossing.
+The pool reuses native views for page changes. Heavy native state is released only on final unmount through `releaseEngine` where the platform needs explicit teardown, which prevents scroll-driven native-view churn and keeps allocations flat during repeated page crossing.
diff --git a/example/package-lock.json b/example/package-lock.json
index dc0664a..502c733 100644
--- a/example/package-lock.json
+++ b/example/package-lock.json
@@ -28,7 +28,7 @@
},
"..": {
"name": "@mathnotes/mobile-ink",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "Apache-2.0",
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -6393,9 +6393,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.49",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
- "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [
{
"type": "opencollective",
@@ -6412,7 +6412,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.7",
+ "nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
diff --git a/example/package.json b/example/package.json
index a6421ca..cb3493f 100644
--- a/example/package.json
+++ b/example/package.json
@@ -27,5 +27,8 @@
"babel-preset-expo": "54.0.10",
"typescript": "~5.9.2",
"@types/react": "^19.1.0"
+ },
+ "overrides": {
+ "postcss": "8.5.10"
}
}
diff --git a/package.json b/package.json
index 0e9c64a..5a319f1 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@mathnotes/mobile-ink",
"version": "0.2.0",
- "description": "Production-grade React Native ink engine with native Skia/Metal drawing and continuous canvas primitives.",
+ "description": "Production-grade React Native ink engine with native Skia drawing and continuous canvas primitives.",
"license": "Apache-2.0",
"author": "BuilderPro LLC",
"private": false,
@@ -21,6 +21,7 @@
"src/benchmark",
"src/utils",
"ios",
+ "android",
"cpp",
"docs/*.md",
"CHANGELOG.md",
diff --git a/react-native.config.js b/react-native.config.js
index 47789b5..bdf4dfa 100644
--- a/react-native.config.js
+++ b/react-native.config.js
@@ -4,7 +4,11 @@ module.exports = {
ios: {
podspecPath: 'MathNotesMobileInk.podspec',
},
- android: null,
+ android: {
+ sourceDir: 'android',
+ packageImportPath: 'import com.mathnotes.mobileink.MobileInkPackage;',
+ packageInstance: 'new MobileInkPackage()',
+ },
},
},
};
diff --git a/src/NativeInkCanvas.tsx b/src/NativeInkCanvas.tsx
index 504bcf9..28b2461 100644
--- a/src/NativeInkCanvas.tsx
+++ b/src/NativeInkCanvas.tsx
@@ -86,7 +86,7 @@ export interface NativeInkCanvasProps {
renderSuspended?: boolean;
/** iOS only: Chooses the native render path for A/B performance tests. */
renderBackend?: NativeInkRenderBackend;
- /** iOS only: Controls whether fingers or only Apple Pencil can draw */
+ /** Controls whether fingers or only stylus/Pencil input can draw. */
drawingPolicy?: 'default' | 'anyinput' | 'pencilonly';
/** iOS only: Fired when Apple Pencil barrel is double-tapped (2nd gen+) */
onPencilDoubleTap?: (event: NativeSyntheticEvent<{ sequence: number; timestamp: number }>) => void;
@@ -761,7 +761,7 @@ export async function batchExportPages(
if (!MobileInkBridge) {
throw new Error('MobileInkBridge not found. Please rebuild the app.');
}
- // iOS: batchExportPages(pagesDataArray, backgroundTypes, width, height, scale, pdfUri)
+ // iOS: batchExportPages(pagesDataArray, backgroundTypes, width, height, scale, pdfUri, pageIndices)
results = await MobileInkBridge.batchExportPages(
sanitizedPagesData,
backgroundTypes,
@@ -775,14 +775,15 @@ export async function batchExportPages(
if (!MobileInkModule) {
throw new Error('MobileInkModule not found. Please rebuild the app.');
}
- // Android: batchExportPages(pagesDataArray, backgroundTypes, width, height, scale, pdfUri)
+ // Android: batchExportPages(pagesDataArray, backgroundTypes, width, height, scale, pdfUri, pageIndices)
results = await MobileInkModule.batchExportPages(
sanitizedPagesData,
backgroundTypes,
width,
height,
scale,
- pdfBackgroundUri || ''
+ pdfBackgroundUri || '',
+ pageIndices || []
);
}