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 Mobile Ink cover drawn inside the example 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 || [] ); }