diff --git a/CLAUDE.md b/CLAUDE.md index 085ed50..ac72623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,3 +16,29 @@ JAVA_HOME="/c/Program Files/Android/Android Studio/jbr" ./gradlew assembleDebug ```bash "/c/Users/Durham/AppData/Local/Android/Sdk/platform-tools/adb.exe" install -r app/build/outputs/apk/debug/app-debug.apk ``` + +### Tests + +```bash +# Unit tests only (no device required) +./gradlew testDebugUnitTest + +# All tests (unit + instrumented, requires connected device) +./gradlew allTests +``` + +## PR Workflow + +Before creating a PR, run the self-review cycle locally: + +1. **Review local changes**: `REVIEW=$(./scripts/review-pr.sh --local --no-post)` — reviews the branch diff against master (use `--base ` to diff against a different branch), saves to `.claude/reviews/`. +2. **Read the review file** (`cat "$REVIEW"`) and address every actionable item by editing the code and committing. +3. **Verify fixes**: `./scripts/review-check.sh --local --no-post "$REVIEW"` — confirm all items are addressed. +4. If any items remain open, go back to step 2. +5. Once all items are resolved, **create the PR** and post the review: + - `gh pr create --draft` (or `gh pr create` if ready) + - `./scripts/review-pr.sh --post` (runs a fresh review and posts to the PR) + - `./scripts/review-check.sh --post "$REVIEW"` (posts the resolution checklist) + - `gh pr ready` (if created as draft) + +The `--local` flag works without a remote or PR. The `--no-post` / `--post` flags make scripts non-interactive for agent use. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e1454f..8cf7804 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,6 +14,8 @@ android { targetSdk = 34 versionCode = 1 versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["notAnnotation"] = "com.writer.recognition.DevTool" } buildTypes { @@ -39,8 +41,21 @@ android { jvmTarget = "17" } + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + all { + it.jvmArgs( + "--add-opens", "java.base/jdk.internal.access=ALL-UNNAMED", + ) + } + } + } + buildFeatures { viewBinding = true + aidl = true } packaging { @@ -50,6 +65,40 @@ android { } } +tasks.register("captureFixture") { + description = "Capture handwriting fixture from device: -PfixtureName=hello -PexpectedText=\"hello\"" + dependsOn("installDebug", "installDebugAndroidTest") + doLast { + val name = project.property("fixtureName") as String + val text = project.property("expectedText") as String + val lang = project.findProperty("language") as? String ?: "en-US" + val line = project.findProperty("lineIndex") as? String ?: "0" + val adb = android.adbExecutable.absolutePath + val appId = "com.writer.dev" + + exec { + commandLine(adb, "shell", "am", "instrument", "-w", + "-e", "class", "com.writer.recognition.StrokeFixtureCapture", + "-e", "fixtureName", name, + "-e", "expectedText", text, + "-e", "language", lang, + "-e", "lineIndex", line, + "$appId.test/androidx.test.runner.AndroidJUnitRunner") + } + + exec { + commandLine(adb, "pull", + "/sdcard/Download/inkup-fixtures/$name.json", + "app/src/androidTest/assets/fixtures/$name.json") + } + + exec { + commandLine(adb, "shell", "rm", + "/sdcard/Download/inkup-fixtures/$name.json") + } + } +} + configurations.all { // Onyx SDK pulls in old pre-AndroidX support libraries that clash with AndroidX exclude(group = "com.android.support", module = "support-compat") @@ -86,4 +135,41 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("com.google.android.material:material:1.12.0") + + // Testing + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20231013") + testImplementation("org.robolectric:robolectric:4.14.1") + + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") +} + +tasks.withType { + testLogging { + events(org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED) + showStandardStreams = false + } + addTestListener(object : TestListener { + override fun beforeSuite(suite: TestDescriptor) {} + override fun afterSuite(suite: TestDescriptor, result: TestResult) { + if (suite.parent == null) { + println("${result.resultType}: ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped") + } + } + override fun beforeTest(testDescriptor: TestDescriptor) {} + override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {} + }) +} + +tasks.register("allTests") { + description = "Runs all tests: unit tests and instrumented tests on connected device" + group = "verification" + dependsOn("testDebugUnitTest", "connectedDebugAndroidTest") +} +// Run unit tests first — fail fast before slower device tests +tasks.matching { it.name == "connectedDebugAndroidTest" }.configureEach { + mustRunAfter("testDebugUnitTest") } diff --git a/app/src/androidTest/assets/fixtures/hello_test.json b/app/src/androidTest/assets/fixtures/hello_test.json new file mode 100644 index 0000000..8743dae --- /dev/null +++ b/app/src/androidTest/assets/fixtures/hello_test.json @@ -0,0 +1,245 @@ +{ + "expectedText": "hello test", + "language": "en-US", + "strokes": [ + { + "strokeId": "s0", + "points": [ + { "x": 59.63, "y": 30.94, "pressure": 1071.0, "timestamp": 1773472267298 }, + { "x": 59.63, "y": 30.94, "pressure": 2865.0, "timestamp": 1773472267366 }, + { "x": 59.83, "y": 34.91, "pressure": 3167.0, "timestamp": 1773472267396 }, + { "x": 58.84, "y": 46.20, "pressure": 3248.0, "timestamp": 1773472267405 }, + { "x": 57.26, "y": 56.10, "pressure": 3284.0, "timestamp": 1773472267416 }, + { "x": 55.28, "y": 65.81, "pressure": 3289.0, "timestamp": 1773472267427 }, + { "x": 53.30, "y": 74.53, "pressure": 3374.0, "timestamp": 1773472267435 }, + { "x": 51.91, "y": 81.46, "pressure": 3351.0, "timestamp": 1773472267447 }, + { "x": 51.31, "y": 87.80, "pressure": 3345.0, "timestamp": 1773472267455 }, + { "x": 51.31, "y": 91.96, "pressure": 3325.0, "timestamp": 1773472267466 }, + { "x": 51.31, "y": 93.54, "pressure": 3298.0, "timestamp": 1773472267484 }, + { "x": 51.31, "y": 93.54, "pressure": 3257.0, "timestamp": 1773472267557 }, + { "x": 54.68, "y": 90.77, "pressure": 3273.0, "timestamp": 1773472267566 }, + { "x": 58.45, "y": 86.41, "pressure": 3279.0, "timestamp": 1773472267576 }, + { "x": 62.41, "y": 82.45, "pressure": 3280.0, "timestamp": 1773472267587 }, + { "x": 65.58, "y": 78.49, "pressure": 3273.0, "timestamp": 1773472267596 }, + { "x": 68.55, "y": 75.52, "pressure": 3262.0, "timestamp": 1773472267609 }, + { "x": 70.53, "y": 74.33, "pressure": 3249.0, "timestamp": 1773472267623 }, + { "x": 72.12, "y": 73.34, "pressure": 3234.0, "timestamp": 1773472267639 }, + { "x": 73.70, "y": 72.74, "pressure": 3221.0, "timestamp": 1773472267655 }, + { "x": 75.29, "y": 72.35, "pressure": 3231.0, "timestamp": 1773472267669 }, + { "x": 77.27, "y": 73.14, "pressure": 3244.0, "timestamp": 1773472267678 }, + { "x": 79.05, "y": 75.52, "pressure": 3446.0, "timestamp": 1773472267690 }, + { "x": 80.44, "y": 79.48, "pressure": 3508.0, "timestamp": 1773472267701 }, + { "x": 81.43, "y": 84.83, "pressure": 3556.0, "timestamp": 1773472267710 }, + { "x": 81.43, "y": 89.19, "pressure": 3568.0, "timestamp": 1773472267721 }, + { "x": 81.43, "y": 93.15, "pressure": 3571.0, "timestamp": 1773472267732 }, + { "x": 81.03, "y": 96.32, "pressure": 3554.0, "timestamp": 1773472267744 }, + { "x": 81.03, "y": 99.09, "pressure": 3288.0, "timestamp": 1773472267755 }, + { "x": 81.23, "y": 101.07, "pressure": 3010.0, "timestamp": 1773472267765 }, + { "x": 81.23, "y": 101.86, "pressure": 2756.0, "timestamp": 1773472267774 }, + { "x": 81.63, "y": 103.05, "pressure": 1398.0, "timestamp": 1773472267785 }, + { "x": 82.62, "y": 104.24, "pressure": 1211.0, "timestamp": 1773472267796 }, + { "x": 82.62, "y": 104.24, "pressure": 1211.0, "timestamp": 1773472267798 } + ] + }, + { + "strokeId": "s1", + "points": [ + { "x": 94.11, "y": 84.83, "pressure": 374.0, "timestamp": 1773472268057 }, + { "x": 94.11, "y": 84.83, "pressure": 2510.0, "timestamp": 1773472268149 }, + { "x": 94.11, "y": 84.83, "pressure": 2659.0, "timestamp": 1773472268157 }, + { "x": 101.64, "y": 85.03, "pressure": 2744.0, "timestamp": 1773472268169 }, + { "x": 106.59, "y": 83.84, "pressure": 2770.0, "timestamp": 1773472268177 }, + { "x": 108.57, "y": 83.04, "pressure": 2792.0, "timestamp": 1773472268191 }, + { "x": 110.16, "y": 81.86, "pressure": 2851.0, "timestamp": 1773472268202 }, + { "x": 111.15, "y": 80.27, "pressure": 2979.0, "timestamp": 1773472268215 }, + { "x": 111.54, "y": 78.69, "pressure": 3094.0, "timestamp": 1773472268223 }, + { "x": 111.54, "y": 78.69, "pressure": 3256.0, "timestamp": 1773472268236 }, + { "x": 110.36, "y": 78.09, "pressure": 3288.0, "timestamp": 1773472268250 }, + { "x": 107.19, "y": 77.70, "pressure": 3294.0, "timestamp": 1773472268261 }, + { "x": 104.02, "y": 78.88, "pressure": 3279.0, "timestamp": 1773472268273 }, + { "x": 102.63, "y": 80.27, "pressure": 3235.0, "timestamp": 1773472268284 }, + { "x": 100.25, "y": 84.23, "pressure": 3209.0, "timestamp": 1773472268295 }, + { "x": 99.85, "y": 87.80, "pressure": 3185.0, "timestamp": 1773472268303 }, + { "x": 100.85, "y": 91.17, "pressure": 3145.0, "timestamp": 1773472268316 }, + { "x": 104.41, "y": 94.53, "pressure": 3126.0, "timestamp": 1773472268332 }, + { "x": 108.37, "y": 96.52, "pressure": 3117.0, "timestamp": 1773472268343 }, + { "x": 112.34, "y": 97.70, "pressure": 3111.0, "timestamp": 1773472268352 }, + { "x": 116.30, "y": 97.51, "pressure": 2270.0, "timestamp": 1773472268363 }, + { "x": 120.46, "y": 95.92, "pressure": 2153.0, "timestamp": 1773472268373 }, + { "x": 120.46, "y": 95.92, "pressure": 2153.0, "timestamp": 1773472268375 } + ] + }, + { + "strokeId": "s2", + "points": [ + { "x": 139.48, "y": 38.87, "pressure": 581.0, "timestamp": 1773472268650 }, + { "x": 139.48, "y": 38.87, "pressure": 2850.0, "timestamp": 1773472268737 }, + { "x": 136.51, "y": 46.79, "pressure": 2999.0, "timestamp": 1773472268751 }, + { "x": 132.94, "y": 57.29, "pressure": 3049.0, "timestamp": 1773472268760 }, + { "x": 130.76, "y": 67.79, "pressure": 3088.0, "timestamp": 1773472268769 }, + { "x": 128.98, "y": 77.10, "pressure": 3095.0, "timestamp": 1773472268780 }, + { "x": 128.78, "y": 84.63, "pressure": 3096.0, "timestamp": 1773472268790 }, + { "x": 129.18, "y": 90.57, "pressure": 3078.0, "timestamp": 1773472268799 }, + { "x": 129.97, "y": 93.54, "pressure": 2807.0, "timestamp": 1773472268810 }, + { "x": 130.76, "y": 96.91, "pressure": 2719.0, "timestamp": 1773472268819 }, + { "x": 130.76, "y": 96.91, "pressure": 1535.0, "timestamp": 1773472268831 }, + { "x": 132.74, "y": 98.30, "pressure": 1501.0, "timestamp": 1773472268840 } + ] + }, + { + "strokeId": "s3", + "points": [ + { "x": 155.73, "y": 41.84, "pressure": 705.0, "timestamp": 1773472269067 }, + { "x": 155.73, "y": 41.84, "pressure": 2923.0, "timestamp": 1773472269131 }, + { "x": 154.74, "y": 45.21, "pressure": 2990.0, "timestamp": 1773472269145 }, + { "x": 151.96, "y": 56.30, "pressure": 3030.0, "timestamp": 1773472269154 }, + { "x": 149.58, "y": 65.81, "pressure": 3043.0, "timestamp": 1773472269165 }, + { "x": 147.60, "y": 74.72, "pressure": 3046.0, "timestamp": 1773472269175 }, + { "x": 145.62, "y": 82.05, "pressure": 3043.0, "timestamp": 1773472269184 }, + { "x": 144.83, "y": 88.19, "pressure": 3023.0, "timestamp": 1773472269195 }, + { "x": 144.43, "y": 92.95, "pressure": 3018.0, "timestamp": 1773472269204 }, + { "x": 144.63, "y": 94.14, "pressure": 2258.0, "timestamp": 1773472269215 }, + { "x": 144.83, "y": 94.53, "pressure": 1438.0, "timestamp": 1773472269226 }, + { "x": 146.61, "y": 95.33, "pressure": 689.0, "timestamp": 1773472269234 }, + { "x": 148.40, "y": 95.33, "pressure": 671.0, "timestamp": 1773472269241 } + ] + }, + { + "strokeId": "s4", + "points": [ + { "x": 167.02, "y": 82.65, "pressure": 402.0, "timestamp": 1773472269373 }, + { "x": 167.02, "y": 82.65, "pressure": 1254.0, "timestamp": 1773472269417 }, + { "x": 168.21, "y": 88.99, "pressure": 1406.0, "timestamp": 1773472269425 }, + { "x": 169.40, "y": 93.54, "pressure": 1595.0, "timestamp": 1773472269436 }, + { "x": 170.19, "y": 94.73, "pressure": 1656.0, "timestamp": 1773472269448 }, + { "x": 172.57, "y": 95.92, "pressure": 1993.0, "timestamp": 1773472269457 }, + { "x": 175.34, "y": 95.72, "pressure": 2261.0, "timestamp": 1773472269468 }, + { "x": 177.72, "y": 94.93, "pressure": 2345.0, "timestamp": 1773472269476 }, + { "x": 179.30, "y": 93.74, "pressure": 2638.0, "timestamp": 1773472269489 }, + { "x": 180.89, "y": 91.36, "pressure": 2936.0, "timestamp": 1773472269500 }, + { "x": 181.48, "y": 88.59, "pressure": 3029.0, "timestamp": 1773472269509 }, + { "x": 181.28, "y": 86.21, "pressure": 3240.0, "timestamp": 1773472269520 }, + { "x": 180.69, "y": 85.03, "pressure": 3355.0, "timestamp": 1773472269532 }, + { "x": 177.12, "y": 83.04, "pressure": 3388.0, "timestamp": 1773472269541 }, + { "x": 174.15, "y": 82.25, "pressure": 3395.0, "timestamp": 1773472269553 }, + { "x": 171.77, "y": 82.05, "pressure": 3341.0, "timestamp": 1773472269566 }, + { "x": 169.40, "y": 82.85, "pressure": 2959.0, "timestamp": 1773472269579 }, + { "x": 169.40, "y": 82.85, "pressure": 2585.0, "timestamp": 1773472269594 } + ] + }, + { + "strokeId": "s5", + "points": [ + { "x": 231.01, "y": 75.71, "pressure": 48.0, "timestamp": 1773472271839 }, + { "x": 231.01, "y": 75.71, "pressure": 3000.0, "timestamp": 1773472271988 }, + { "x": 232.00, "y": 75.52, "pressure": 3064.0, "timestamp": 1773472272003 }, + { "x": 239.53, "y": 73.14, "pressure": 3080.0, "timestamp": 1773472272011 }, + { "x": 244.68, "y": 72.15, "pressure": 3085.0, "timestamp": 1773472272022 }, + { "x": 249.44, "y": 71.55, "pressure": 3089.0, "timestamp": 1773472272032 }, + { "x": 253.40, "y": 71.16, "pressure": 3092.0, "timestamp": 1773472272041 }, + { "x": 258.16, "y": 70.17, "pressure": 3096.0, "timestamp": 1773472272052 }, + { "x": 263.11, "y": 69.18, "pressure": 3094.0, "timestamp": 1773472272063 }, + { "x": 268.06, "y": 68.19, "pressure": 2333.0, "timestamp": 1773472272071 }, + { "x": 271.03, "y": 67.59, "pressure": 2312.0, "timestamp": 1773472272081 } + ] + }, + { + "strokeId": "s6", + "points": [ + { "x": 256.77, "y": 52.54, "pressure": 903.0, "timestamp": 1773472272259 }, + { "x": 256.77, "y": 52.54, "pressure": 2766.0, "timestamp": 1773472272316 }, + { "x": 254.59, "y": 58.08, "pressure": 2860.0, "timestamp": 1773472272328 }, + { "x": 251.62, "y": 67.39, "pressure": 2912.0, "timestamp": 1773472272340 }, + { "x": 249.64, "y": 75.32, "pressure": 2929.0, "timestamp": 1773472272348 }, + { "x": 248.05, "y": 81.86, "pressure": 2927.0, "timestamp": 1773472272359 }, + { "x": 247.85, "y": 87.80, "pressure": 2922.0, "timestamp": 1773472272370 }, + { "x": 248.05, "y": 92.55, "pressure": 2916.0, "timestamp": 1773472272378 }, + { "x": 248.65, "y": 94.93, "pressure": 2539.0, "timestamp": 1773472272389 }, + { "x": 249.24, "y": 97.11, "pressure": 1827.0, "timestamp": 1773472272401 }, + { "x": 252.81, "y": 99.88, "pressure": 1188.0, "timestamp": 1773472272410 }, + { "x": 254.59, "y": 100.68, "pressure": 1173.0, "timestamp": 1773472272419 } + ] + }, + { + "strokeId": "s7", + "points": [ + { "x": 278.17, "y": 92.16, "pressure": 601.0, "timestamp": 1773472272581 }, + { "x": 278.17, "y": 92.16, "pressure": 2719.0, "timestamp": 1773472272658 }, + { "x": 284.11, "y": 88.00, "pressure": 2794.0, "timestamp": 1773472272667 }, + { "x": 288.27, "y": 84.63, "pressure": 2888.0, "timestamp": 1773472272678 }, + { "x": 289.66, "y": 83.24, "pressure": 2941.0, "timestamp": 1773472272690 }, + { "x": 290.05, "y": 82.05, "pressure": 2977.0, "timestamp": 1773472272700 }, + { "x": 290.65, "y": 79.48, "pressure": 3037.0, "timestamp": 1773472272710 }, + { "x": 290.45, "y": 77.10, "pressure": 3095.0, "timestamp": 1773472272725 }, + { "x": 290.25, "y": 76.71, "pressure": 3142.0, "timestamp": 1773472272739 }, + { "x": 288.87, "y": 75.91, "pressure": 3171.0, "timestamp": 1773472272747 }, + { "x": 284.51, "y": 75.32, "pressure": 3128.0, "timestamp": 1773472272758 }, + { "x": 280.54, "y": 77.70, "pressure": 3123.0, "timestamp": 1773472272769 }, + { "x": 277.18, "y": 81.66, "pressure": 3101.0, "timestamp": 1773472272778 }, + { "x": 274.80, "y": 85.82, "pressure": 3074.0, "timestamp": 1773472272789 }, + { "x": 273.02, "y": 90.37, "pressure": 3068.0, "timestamp": 1773472272797 }, + { "x": 273.02, "y": 93.74, "pressure": 3019.0, "timestamp": 1773472272808 }, + { "x": 274.40, "y": 97.11, "pressure": 2985.0, "timestamp": 1773472272821 }, + { "x": 276.58, "y": 99.49, "pressure": 2859.0, "timestamp": 1773472272836 }, + { "x": 280.54, "y": 101.67, "pressure": 2746.0, "timestamp": 1773472272843 }, + { "x": 284.51, "y": 102.06, "pressure": 1545.0, "timestamp": 1773472272854 }, + { "x": 289.46, "y": 100.87, "pressure": 1379.0, "timestamp": 1773472272866 }, + { "x": 289.46, "y": 100.87, "pressure": 1379.0, "timestamp": 1773472272867 } + ] + }, + { + "strokeId": "s8", + "points": [ + { "x": 320.96, "y": 74.13, "pressure": 836.0, "timestamp": 1773472273014 }, + { "x": 320.96, "y": 74.13, "pressure": 2774.0, "timestamp": 1773472273079 }, + { "x": 316.01, "y": 75.71, "pressure": 2805.0, "timestamp": 1773472273111 }, + { "x": 311.06, "y": 77.89, "pressure": 2811.0, "timestamp": 1773472273123 }, + { "x": 309.67, "y": 79.28, "pressure": 2785.0, "timestamp": 1773472273132 }, + { "x": 308.88, "y": 81.26, "pressure": 2733.0, "timestamp": 1773472273145 }, + { "x": 309.27, "y": 84.03, "pressure": 2694.0, "timestamp": 1773472273159 }, + { "x": 310.46, "y": 86.41, "pressure": 2687.0, "timestamp": 1773472273172 }, + { "x": 314.03, "y": 89.58, "pressure": 2686.0, "timestamp": 1773472273186 }, + { "x": 317.99, "y": 91.96, "pressure": 2866.0, "timestamp": 1773472273195 }, + { "x": 319.97, "y": 92.95, "pressure": 2889.0, "timestamp": 1773472273206 }, + { "x": 322.35, "y": 94.14, "pressure": 2901.0, "timestamp": 1773472273220 }, + { "x": 322.35, "y": 94.14, "pressure": 3233.0, "timestamp": 1773472273252 }, + { "x": 320.76, "y": 96.91, "pressure": 3306.0, "timestamp": 1773472273260 }, + { "x": 315.02, "y": 99.88, "pressure": 3308.0, "timestamp": 1773472273271 }, + { "x": 308.08, "y": 101.86, "pressure": 3309.0, "timestamp": 1773472273283 }, + { "x": 301.15, "y": 103.65, "pressure": 3193.0, "timestamp": 1773472273291 }, + { "x": 295.60, "y": 104.04, "pressure": 2909.0, "timestamp": 1773472273302 }, + { "x": 293.62, "y": 104.04, "pressure": 2817.0, "timestamp": 1773472273310 }, + { "x": 293.62, "y": 104.04, "pressure": 1446.0, "timestamp": 1773472273332 } + ] + }, + { + "strokeId": "s9", + "points": [ + { "x": 331.26, "y": 74.72, "pressure": 633.0, "timestamp": 1773472273559 }, + { "x": 331.26, "y": 74.72, "pressure": 2992.0, "timestamp": 1773472273646 }, + { "x": 339.59, "y": 75.91, "pressure": 2999.0, "timestamp": 1773472273657 }, + { "x": 348.10, "y": 75.91, "pressure": 3000.0, "timestamp": 1773472273667 }, + { "x": 355.83, "y": 75.32, "pressure": 2993.0, "timestamp": 1773472273676 }, + { "x": 361.97, "y": 74.33, "pressure": 2072.0, "timestamp": 1773472273687 }, + { "x": 366.93, "y": 73.34, "pressure": 1774.0, "timestamp": 1773472273695 }, + { "x": 367.72, "y": 73.14, "pressure": 1769.0, "timestamp": 1773472273701 } + ] + }, + { + "strokeId": "s10", + "points": [ + { "x": 354.64, "y": 53.73, "pressure": 1142.0, "timestamp": 1773472273832 }, + { "x": 354.64, "y": 53.73, "pressure": 3160.0, "timestamp": 1773472273885 }, + { "x": 351.87, "y": 61.65, "pressure": 3318.0, "timestamp": 1773472273897 }, + { "x": 349.29, "y": 70.17, "pressure": 3339.0, "timestamp": 1773472273908 }, + { "x": 347.51, "y": 78.88, "pressure": 3356.0, "timestamp": 1773472273917 }, + { "x": 346.92, "y": 85.03, "pressure": 3335.0, "timestamp": 1773472273928 }, + { "x": 347.11, "y": 90.57, "pressure": 3330.0, "timestamp": 1773472273936 }, + { "x": 347.71, "y": 94.53, "pressure": 2685.0, "timestamp": 1773472273947 }, + { "x": 348.70, "y": 96.12, "pressure": 1491.0, "timestamp": 1773472273958 }, + { "x": 350.88, "y": 98.30, "pressure": 374.0, "timestamp": 1773472273966 }, + { "x": 351.27, "y": 98.50, "pressure": 347.0, "timestamp": 1773472273974 } + ] + } + ] +} diff --git a/app/src/androidTest/java/com/writer/recognition/DevTool.kt b/app/src/androidTest/java/com/writer/recognition/DevTool.kt new file mode 100644 index 0000000..5af8fd0 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/DevTool.kt @@ -0,0 +1,4 @@ +package com.writer.recognition + +/** Marker for dev-tool "tests" that should only run when explicitly invoked. */ +annotation class DevTool diff --git a/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt b/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt new file mode 100644 index 0000000..f95fca0 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt @@ -0,0 +1,55 @@ +package com.writer.recognition + +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.InkLine +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.json.JSONObject + +object FixtureLoader { + + data class Fixture( + val expectedText: String, + val language: String, + val inkLine: InkLine + ) + + fun load(name: String): Fixture { + val context = InstrumentationRegistry.getInstrumentation().context + val jsonText = context.assets.open("fixtures/$name.json") + .bufferedReader().use { it.readText() } + val json = JSONObject(jsonText) + + val expectedText = json.getString("expectedText") + val language = json.optString("language", "en-US") + + val strokes = mutableListOf() + val strokesArr = json.getJSONArray("strokes") + for (i in 0 until strokesArr.length()) { + val strokeObj = strokesArr.getJSONObject(i) + val strokeId = strokeObj.getString("strokeId") + + val pointsArr = strokeObj.getJSONArray("points") + val points = mutableListOf() + for (j in 0 until pointsArr.length()) { + val ptObj = pointsArr.getJSONObject(j) + points.add( + StrokePoint( + x = ptObj.getDouble("x").toFloat(), + y = ptObj.getDouble("y").toFloat(), + pressure = ptObj.getDouble("pressure").toFloat(), + timestamp = ptObj.getLong("timestamp") + ) + ) + } + + strokes.add(InkStroke(strokeId = strokeId, points = points)) + } + + return Fixture( + expectedText = expectedText, + language = language, + inkLine = InkLine.build(strokes) + ) + } +} diff --git a/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt b/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt new file mode 100644 index 0000000..9ce839f --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt @@ -0,0 +1,79 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.RectF +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.InkLine +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Connected test that exercises the full text recognition pipeline on a Boox device: + * service binding → initialization → protobuf encoding → SharedMemory IPC → result parsing. + * + * Skipped on non-Boox devices where KHwrService is unavailable. + */ +@RunWith(AndroidJUnit4::class) +class OnyxHwrTextRecognizerTest { + + private lateinit var recognizer: OnyxHwrTextRecognizer + private var hwrAvailable = false + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + hwrAvailable = isHwrServiceAvailable(context) + assumeTrue("KHwrService not available — skipping on non-Boox device", hwrAvailable) + recognizer = OnyxHwrTextRecognizer(context) + } + + @After + fun tearDown() { + if (hwrAvailable) { + recognizer.close() + } + } + + @Test + fun initialize_bindsAndActivates() = runBlocking { + recognizer.initialize("en-US") + } + + @Test + fun recognizeLine_emptyStrokes_returnsEmpty() = runBlocking { + recognizer.initialize("en-US") + val line = InkLine(emptyList(), RectF()) + val result = recognizer.recognizeLine(line, "") + assertTrue("Empty strokes should return empty string", result.isEmpty()) + } + + @Test + fun recognizeLine_capturedHelloTest_recognizesCorrectly() = runBlocking { + recognizer.initialize("en-US") + val fixture = FixtureLoader.load("hello_test") + assertEquals(fixture.expectedText, recognizer.recognizeLine(fixture.inkLine, "")) + } + + // --- Helpers --- + + private fun isHwrServiceAvailable(context: android.content.Context): Boolean { + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + return context.packageManager.resolveService( + intent, PackageManager.ResolveInfoFlags.of(0) + ) != null + } +} diff --git a/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt b/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt new file mode 100644 index 0000000..f84bbc2 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt @@ -0,0 +1,92 @@ +package com.writer.recognition + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.StrokePoint +import com.writer.storage.DocumentStorage +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Instrumented test that captures handwriting from a document on device + * and writes a downsampled JSON fixture to /sdcard/Download/inkup-fixtures/. + * + * Run via the captureFixture Gradle task, or directly: + * adb shell am instrument -w \ + * -e class com.writer.recognition.StrokeFixtureCapture \ + * -e fixtureName hello_test \ + * -e expectedText "hello test" \ + * com.writer.dev.test/androidx.test.runner.AndroidJUnitRunner + */ +@RunWith(AndroidJUnit4::class) +@DevTool +class StrokeFixtureCapture { + + @Test + fun captureFixture() { + val args = InstrumentationRegistry.getArguments() + val fixtureName = args.getString("fixtureName") + val expectedText = args.getString("expectedText") + assumeTrue("Skipped — run via captureFixture Gradle task", fixtureName != null && expectedText != null) + fixtureName!! + expectedText!! + val language = args.getString("language") ?: "en-US" + val lineIndex = args.getString("lineIndex")?.toIntOrNull() ?: 0 + val documentName = args.getString("documentName") + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + // Load the most recent document, or a named one + val docName = documentName ?: run { + val docs = DocumentStorage.listDocuments(context) + require(docs.isNotEmpty()) { "No documents found on device" } + docs.first().name + } + val data = requireNotNull(DocumentStorage.load(context, docName)) { + "Failed to load document: $docName" + } + require(data.strokes.isNotEmpty()) { "Document has no strokes" } + + // Segment strokes by line + val segmenter = LineSegmenter() + val lineStrokes = segmenter.getStrokesForLine(data.strokes, lineIndex) + require(lineStrokes.isNotEmpty()) { + "No strokes found on line $lineIndex. " + + "Available lines: ${segmenter.groupByLine(data.strokes).keys.sorted()}" + } + + // Build fixture JSON with downsampled strokes + val json = JSONObject().apply { + put("expectedText", expectedText) + put("language", language) + put("strokes", JSONArray().apply { + for ((i, stroke) in lineStrokes.sortedBy { it.points.first().x }.withIndex()) { + val downsampled = StrokeDownsampler.downsample(stroke) + put(JSONObject().apply { + put("strokeId", "s$i") + put("points", JSONArray().apply { + for (pt in downsampled.points) { + put(JSONObject().apply { + put("x", pt.x.toDouble()) + put("y", pt.y.toDouble()) + put("pressure", pt.pressure.toDouble()) + put("timestamp", pt.timestamp) + }) + } + }) + }) + } + }) + } + + // Write to /sdcard/Download/inkup-fixtures/ + val outDir = File("/sdcard/Download/inkup-fixtures") + outDir.mkdirs() + val outFile = File(outDir, "$fixtureName.json") + outFile.writeText(json.toString(2)) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 13952f2..3f92ec6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + - - diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl new file mode 100644 index 0000000..008e543 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWRCommandArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl new file mode 100644 index 0000000..c47a0d8 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWRInputArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl new file mode 100644 index 0000000..d325ae0 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWROutputArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl new file mode 100644 index 0000000..9259259 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl @@ -0,0 +1,7 @@ +package com.onyx.android.sdk.hwr.service; + +import com.onyx.android.sdk.hwr.service.HWROutputArgs; + +interface HWROutputCallback { + void read(in HWROutputArgs args); +} diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl new file mode 100644 index 0000000..b3d9dea --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl @@ -0,0 +1,16 @@ +package com.onyx.android.sdk.hwr.service; + +import android.os.ParcelFileDescriptor; +import com.onyx.android.sdk.hwr.service.HWROutputCallback; +import com.onyx.android.sdk.hwr.service.HWRInputArgs; +import com.onyx.android.sdk.hwr.service.HWRCommandArgs; + +// Method order determines transaction codes — must match the service exactly. +oneway interface IHWRService { + void init(in HWRInputArgs args, boolean forceReinit, HWROutputCallback callback); + void compileRecognizeText(String text, String language, HWROutputCallback callback); + void batchRecognize(in ParcelFileDescriptor pfd, HWROutputCallback callback); + void openIncrementalRecognizer(in HWRInputArgs args, HWROutputCallback callback); + void execCommand(in HWRInputArgs args, in HWRCommandArgs cmdArgs, HWROutputCallback callback); + void closeRecognizer(); +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt new file mode 100644 index 0000000..ce6546f --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt @@ -0,0 +1,17 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.Parcelable + +/** Stub parcelable required by AIDL — not used in batchRecognize path. */ +class HWRCommandArgs() : Parcelable { + constructor(parcel: Parcel) : this() + + override fun writeToParcel(parcel: Parcel, flags: Int) {} + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWRCommandArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt new file mode 100644 index 0000000..7fdb66a --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt @@ -0,0 +1,76 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.ParcelFileDescriptor +import android.os.Parcelable + +/** + * Parcelable matching the Boox ksync service's HWRInputArgs. + * Field order must match the service's classloader expectations exactly. + */ +class HWRInputArgs() : Parcelable { + var lang: String = "en_US" + var contentType: String = "Text" + var recognizerType: String = "Text" + var viewWidth: Float = 0f + var viewHeight: Float = 0f + var offsetX: Float = 0f + var offsetY: Float = 0f + var isGestureEnable: Boolean = false + var isTextEnable: Boolean = true + var isShapeEnable: Boolean = false + var isIncremental: Boolean = false + + var pfd: ParcelFileDescriptor? = null + var content: String? = null + + constructor(parcel: Parcel) : this() { + val className = parcel.readString() + if (className != null) { + lang = parcel.readString() ?: "en_US" + contentType = parcel.readString() ?: "Text" + recognizerType = parcel.readString() ?: "Text" + viewWidth = parcel.readFloat() + viewHeight = parcel.readFloat() + offsetX = parcel.readFloat() + offsetY = parcel.readFloat() + isGestureEnable = parcel.readByte() != 0.toByte() + isTextEnable = parcel.readByte() != 0.toByte() + isShapeEnable = parcel.readByte() != 0.toByte() + isIncremental = parcel.readByte() != 0.toByte() + pfd = parcel.readParcelable(ParcelFileDescriptor::class.java.classLoader, ParcelFileDescriptor::class.java) + content = parcel.readString() + } + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString("com.onyx.android.sdk.hwr.bean.HWRInputData") + parcel.writeString(lang) + parcel.writeString(contentType) + parcel.writeString(recognizerType) + parcel.writeFloat(viewWidth) + parcel.writeFloat(viewHeight) + parcel.writeFloat(offsetX) + parcel.writeFloat(offsetY) + parcel.writeByte(if (isGestureEnable) 1 else 0) + parcel.writeByte(if (isTextEnable) 1 else 0) + parcel.writeByte(if (isShapeEnable) 1 else 0) + parcel.writeByte(if (isIncremental) 1 else 0) + val localPfd = pfd + if (localPfd != null) { + parcel.writeString("android.os.ParcelFileDescriptor") + localPfd.writeToParcel(parcel, flags) + } else { + parcel.writeString(null) + } + parcel.writeString(content) + } + + override fun describeContents(): Int = + if (pfd != null) Parcelable.CONTENTS_FILE_DESCRIPTOR else 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWRInputArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt new file mode 100644 index 0000000..8251f92 --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt @@ -0,0 +1,47 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.ParcelFileDescriptor +import android.os.Parcelable + +/** + * Parcelable matching the Boox KHwrService output format. + * Field order must match the service's writeToParcel exactly. + */ +class HWROutputArgs() : Parcelable { + var pfd: ParcelFileDescriptor? = null + var recognizerActivated: Boolean = false + var compileSuccess: Boolean = false + var hwrResult: String? = null + var gesture: String? = null + var outputType: Int = 0 + var itemIdMap: String? = null + + constructor(parcel: Parcel) : this() { + pfd = parcel.readParcelable(ParcelFileDescriptor::class.java.classLoader, ParcelFileDescriptor::class.java) + recognizerActivated = parcel.readByte() != 0.toByte() + compileSuccess = parcel.readByte() != 0.toByte() + hwrResult = parcel.readString() + gesture = parcel.readString() + outputType = parcel.readInt() + itemIdMap = parcel.readString() + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(pfd, flags) + parcel.writeByte(if (recognizerActivated) 1 else 0) + parcel.writeByte(if (compileSuccess) 1 else 0) + parcel.writeString(hwrResult) + parcel.writeString(gesture) + parcel.writeInt(outputType) + parcel.writeString(itemIdMap) + } + + override fun describeContents(): Int = + if (pfd != null) Parcelable.CONTENTS_FILE_DESCRIPTOR else 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWROutputArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt b/app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt similarity index 83% rename from app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt rename to app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt index e5604b4..46f5950 100644 --- a/app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt +++ b/app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt @@ -10,17 +10,21 @@ import com.google.mlkit.vision.digitalink.recognition.WritingArea import com.writer.model.InkLine import kotlinx.coroutines.tasks.await -class HandwritingRecognizer { +/** + * Google ML Kit Digital Ink recognition engine. + * Works on all Android devices. Downloads language models on first use. + */ +class GoogleMLKitTextRecognizer : TextRecognizer { companion object { - private const val TAG = "HandwritingRecognizer" + private const val TAG = "GoogleMLKitTextRecognizer" private const val PRE_CONTEXT_LENGTH = 20 } private var recognizer: DigitalInkRecognizer? = null private val modelManager = ModelManager() - suspend fun initialize(languageTag: String) { + override suspend fun initialize(languageTag: String) { val model = modelManager.ensureModelDownloaded(languageTag) recognizer = DigitalInkRecognition.getClient( DigitalInkRecognizerOptions.builder(model).build() @@ -28,7 +32,7 @@ class HandwritingRecognizer { Log.i(TAG, "Recognizer initialized for $languageTag") } - suspend fun recognizeLine(line: InkLine, preContext: String = ""): String { + override suspend fun recognizeLine(line: InkLine, preContext: String): String { val rec = recognizer ?: throw IllegalStateException("Recognizer not initialized") val inkBuilder = Ink.builder() @@ -54,7 +58,7 @@ class HandwritingRecognizer { return text } - fun close() { + override fun close() { recognizer?.close() recognizer = null } diff --git a/app/src/main/java/com/writer/recognition/HwrProtobuf.kt b/app/src/main/java/com/writer/recognition/HwrProtobuf.kt new file mode 100644 index 0000000..7895c21 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/HwrProtobuf.kt @@ -0,0 +1,139 @@ +package com.writer.recognition + +import android.util.Log +import com.writer.model.InkLine +import org.json.JSONObject +import java.io.ByteArrayOutputStream + +/** + * Hand-rolled protobuf encoding for the Boox MyScript HWR service. + * No protobuf library dependency — encodes directly to the wire format + * expected by `com.onyx.android.ksync.service.KHwrService.batchRecognize()`. + * + * Extracted from the recognition engine for testability. + */ +object HwrProtobuf { + + /** + * Build the top-level HWRInputProto protobuf bytes from an [InkLine]. + * + * Field numbers (from HWRInputDataProto.HWRInputProto): + * 1: lang (string), 2: contentType (string), 4: recognizerType (string), + * 5: viewWidth (float), 6: viewHeight (float), + * 10: recognizeText (bool), 15: repeated pointerEvents + */ + fun buildProtobuf( + line: InkLine, viewWidth: Float, viewHeight: Float, lang: String = "en_US" + ): ByteArray { + val out = ByteArrayOutputStream() + + writeTag(out, 1, 2); writeString(out, lang) + writeTag(out, 2, 2); writeString(out, "Text") + writeTag(out, 4, 2); writeString(out, "MS_ON_SCREEN") + writeTag(out, 5, 5); writeFixed32(out, viewWidth) + writeTag(out, 6, 5); writeFixed32(out, viewHeight) + writeTag(out, 10, 0); writeVarint(out, 1) // recognizeText = true + + val pointerBuf = ByteArrayOutputStream(64) + for (stroke in line.strokes) { + val points = stroke.points + if (points.isEmpty()) continue + + for ((i, point) in points.withIndex()) { + val isFirst = i == 0 + val isLast = i == points.size - 1 + val eventTypes = when { + isFirst && isLast -> listOf(0, 2) // single-point: DOWN then UP + isFirst -> listOf(0) // DOWN + isLast -> listOf(2) // UP + else -> listOf(1) // MOVE + } + for (eventType in eventTypes) { + val pointerBytes = encodePointerProto( + point.x, point.y, point.timestamp, point.pressure, + pointerId = 0, eventType = eventType, pointerType = 0, + reuse = pointerBuf + ) + writeTag(out, 15, 2) + writeBytes(out, pointerBytes) + } + } + } + return out.toByteArray() + } + + /** + * Encode a single HWRPointerProto message. + * Fields: float x(1), float y(2), sint64 t(3), float f(4), + * sint32 pointerId(5), enum eventType(6), enum pointerType(7) + */ + internal fun encodePointerProto( + x: Float, y: Float, t: Long, f: Float, + pointerId: Int, eventType: Int, pointerType: Int, + reuse: ByteArrayOutputStream? = null + ): ByteArray { + val out = reuse?.apply { reset() } ?: ByteArrayOutputStream(64) + writeTag(out, 1, 5); writeFixed32(out, x) + writeTag(out, 2, 5); writeFixed32(out, y) + writeTag(out, 3, 0); writeVarint(out, (t shl 1) xor (t shr 63)) + writeTag(out, 4, 5); writeFixed32(out, f) + writeTag(out, 5, 0); writeVarint(out, ((pointerId shl 1) xor (pointerId shr 31)).toLong()) + writeTag(out, 6, 0); writeVarint(out, eventType.toLong()) + writeTag(out, 7, 0); writeVarint(out, pointerType.toLong()) + return out.toByteArray() + } + + // --- Protobuf primitives --- + + internal fun writeTag(out: ByteArrayOutputStream, fieldNumber: Int, wireType: Int) { + writeVarint(out, ((fieldNumber shl 3) or wireType).toLong()) + } + + internal fun writeVarint(out: ByteArrayOutputStream, value: Long) { + var v = value + while (v and 0x7FL.inv() != 0L) { + out.write(((v.toInt() and 0x7F) or 0x80)) + v = v ushr 7 + } + out.write(v.toInt() and 0x7F) + } + + internal fun writeFixed32(out: ByteArrayOutputStream, value: Float) { + val bits = java.lang.Float.floatToIntBits(value) + out.write(bits and 0xFF) + out.write((bits shr 8) and 0xFF) + out.write((bits shr 16) and 0xFF) + out.write((bits shr 24) and 0xFF) + } + + internal fun writeString(out: ByteArrayOutputStream, value: String) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeVarint(out, bytes.size.toLong()) + out.write(bytes) + } + + internal fun writeBytes(out: ByteArrayOutputStream, bytes: ByteArray) { + writeVarint(out, bytes.size.toLong()) + out.write(bytes) + } + + // --- Result parsing --- + + /** + * Parse the JSON result from the HWR service. + * Success: `{"result":{"label":"recognized text"}}` + * Error: `{"exception":{"cause":{"message":"..."}}}` → returns empty string + */ + fun parseHwrResult(json: String): String { + return try { + val obj = JSONObject(json) + if (obj.has("exception")) return "" + val result = obj.optJSONObject("result") + if (result != null) return result.optString("label", "") + obj.optString("label", "") + } catch (e: Exception) { + Log.w("HwrProtobuf", "Failed to parse HWR result: ${e.message}") + "" + } + } +} diff --git a/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt b/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt new file mode 100644 index 0000000..76632e0 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt @@ -0,0 +1,226 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.util.Log +import com.onyx.android.sdk.hwr.service.HWRInputArgs +import com.onyx.android.sdk.hwr.service.HWROutputArgs +import com.onyx.android.sdk.hwr.service.HWROutputCallback +import com.onyx.android.sdk.hwr.service.IHWRService +import com.writer.model.InkLine +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.FileInputStream +import java.io.FileOutputStream +import kotlin.coroutines.resume + +/** + * Handwriting recognition using the Boox firmware's built-in MyScript engine. + * Communicates via AIDL IPC with `com.onyx.android.ksync.service.KHwrService`. + * + * Each activity should create its own instance via [TextRecognizerFactory.create] + * and close it in `onDestroy`. + * + * Based on the approach used by [Notable](https://github.com/jshph/notable). + */ +class OnyxHwrTextRecognizer(private val context: Context) : TextRecognizer { + + companion object { + private const val TAG = "OnyxHwrTextRecognizer" + private const val SERVICE_PACKAGE = "com.onyx.android.ksync" + private const val SERVICE_CLASS = "com.onyx.android.ksync.service.KHwrService" + private const val BIND_TIMEOUT_MS = 3000L + private const val RECOGNIZE_TIMEOUT_MS = 10_000L + } + + @Volatile private var service: IHWRService? = null + @Volatile private var bound = false + @Volatile private var initialized = false + private var connectDeferred = CompletableDeferred() + private val initMutex = Mutex() + private var currentLang = "en_US" + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + service = IHWRService.Stub.asInterface(binder) + bound = true + Log.i(TAG, "HWR service connected") + connectDeferred.complete(Unit) + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + bound = false + initialized = false + Log.w(TAG, "HWR service disconnected") + } + } + + override suspend fun initialize(languageTag: String) { + initMutex.withLock { + if (bound && service != null) return@withLock + + connectDeferred = CompletableDeferred() + currentLang = languageTag.replace("-", "_") + val intent = Intent().apply { + component = ComponentName(SERVICE_PACKAGE, SERVICE_CLASS) + } + + val bindStarted = try { + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } catch (e: Exception) { + Log.w(TAG, "Failed to bind HWR service: ${e.message}") + throw IllegalStateException("Onyx HWR service not available", e) + } + + if (!bindStarted) { + throw IllegalStateException("Onyx HWR service not found — is this a Boox device?") + } + + val connected = try { + withTimeoutOrNull(BIND_TIMEOUT_MS) { + connectDeferred.await() + } != null + } catch (e: kotlinx.coroutines.CancellationException) { + context.unbindService(connection) + throw e + } + if (!connected || service == null) { + context.unbindService(connection) + throw IllegalStateException("Onyx HWR service bind timed out") + } + + val svc = service!! + val inputArgs = HWRInputArgs().apply { + lang = currentLang + contentType = "Text" + recognizerType = "MS_ON_SCREEN" + viewWidth = 1000f // arbitrary default; per-recognition dimensions are in the protobuf + viewHeight = 200f + isTextEnable = true + } + + suspendCancellableCoroutine { cont -> + svc.init(inputArgs, true, object : HWROutputCallback.Stub() { + override fun read(args: HWROutputArgs?) { + initialized = args?.recognizerActivated == true + Log.i(TAG, "HWR init: activated=$initialized") + if (cont.isActive) cont.resume(Unit) + } + }) + } + + if (!initialized) { + close() + throw IllegalStateException("MyScript recognizer failed to activate") + } + Log.i(TAG, "Recognizer initialized for $languageTag") + } + } + + // Note: preContext is accepted by the interface but not used here — + // MyScript context is set via HWRInputArgs on init, not per-recognition. + override suspend fun recognizeLine(line: InkLine, preContext: String): String { + val svc = service ?: throw IllegalStateException("Recognizer not initialized") + if (!initialized) throw IllegalStateException("Recognizer not initialized") + if (line.strokes.isEmpty()) return "" + + val bb = line.boundingBox + val viewWidth = if (bb.width() > 0) bb.width() else 1000f + val viewHeight = if (bb.height() > 0) bb.height() else 200f + + val protoBytes = HwrProtobuf.buildProtobuf(line, viewWidth, viewHeight, currentLang) + + val pipe = try { + ParcelFileDescriptor.createPipe() + } catch (e: Exception) { + Log.e(TAG, "Failed to create pipe: ${e.message}") + return "" + } + val readPfd = pipe[0] + val writePfd = pipe[1] + + // Write pipe data concurrently so the service can drain while we write. + // This prevents deadlock when protoBytes exceeds the kernel pipe buffer (~64KB). + val writeJob = CoroutineScope(Dispatchers.IO).launch { + try { + FileOutputStream(writePfd.fileDescriptor).use { it.write(protoBytes) } + } catch (e: Exception) { + Log.e(TAG, "Pipe write failed: ${e.message}") + } finally { + writePfd.close() + } + } + + return try { + val result = withTimeoutOrNull(RECOGNIZE_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + svc.batchRecognize(readPfd, object : HWROutputCallback.Stub() { + override fun read(args: HWROutputArgs?) { + if (!cont.isActive) return + try { + // Boox API: hwrResult is populated only on error; on success it's null and pfd carries the result + val errorJson = args?.hwrResult + if (!errorJson.isNullOrBlank()) { + Log.e(TAG, "HWR error: ${errorJson.take(300)}") + cont.resume("") + return + } + val resultPfd = args?.pfd + if (resultPfd == null) { + cont.resume("") + return + } + val json = readPfdAsString(resultPfd) + resultPfd.close() + val text = HwrProtobuf.parseHwrResult(json) + Log.d(TAG, "Recognized: \"$text\"") + cont.resume(text) + } catch (e: Exception) { + Log.e(TAG, "Error parsing HWR result: ${e.message}") + cont.resume("") + } + } + }) + } + } + result ?: "" + } finally { + writeJob.cancel() + readPfd.close() + } + } + + override fun close() { + if (!bound) return + try { + // closeRecognizer() is oneway (fire-and-forget); unbindService follows immediately + // so the remote recognizer may not process the close before the transport tears down + service?.closeRecognizer() + context.unbindService(connection) + } catch (e: Exception) { + Log.w(TAG, "Error closing HWR service: ${e.message}") + } + bound = false + service = null + initialized = false + } + + private fun readPfdAsString(pfd: ParcelFileDescriptor): String { + return FileInputStream(pfd.fileDescriptor).use { input -> + input.readBytes().toString(Charsets.UTF_8) + } + } + +} diff --git a/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt b/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt new file mode 100644 index 0000000..bff09db --- /dev/null +++ b/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt @@ -0,0 +1,89 @@ +package com.writer.recognition + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import kotlin.math.abs +import kotlin.math.sqrt + +object StrokeDownsampler { + + private const val DWELL_EPSILON = 0.5f // sub-pixel threshold for dwell collapse + + /** + * Collapse runs of nearly-identical (x,y) positions to first + last point. + * Handles pen-down dwell where the device reports many points at the same location. + */ + fun collapseIdenticalPositions(points: List): List { + if (points.size <= 1) return points + val result = mutableListOf() + var runStart = 0 + for (i in 1..points.size) { + val samePos = i < points.size && + abs(points[i].x - points[runStart].x) < DWELL_EPSILON && + abs(points[i].y - points[runStart].y) < DWELL_EPSILON + if (!samePos) { + result.add(points[runStart]) + if (i - 1 > runStart) { + result.add(points[i - 1]) + } + runStart = i + } + } + return result + } + + /** + * Ramer-Douglas-Peucker simplification on (x,y). + * Preserves pressure and timestamp of surviving points. + */ + fun rdp(points: List, epsilon: Float): List { + if (points.size <= 2) return points + + // Find the point with the maximum distance from the line (first, last) + val first = points.first() + val last = points.last() + var maxDist = 0f + var maxIdx = 0 + for (i in 1 until points.size - 1) { + val d = perpendicularDistance(points[i], first, last) + if (d > maxDist) { + maxDist = d + maxIdx = i + } + } + + return if (maxDist > epsilon) { + val left = rdp(points.subList(0, maxIdx + 1), epsilon) + val right = rdp(points.subList(maxIdx, points.size), epsilon) + left.dropLast(1) + right + } else { + listOf(first, last) + } + } + + /** + * Full downsampling pipeline: collapse dwells then RDP simplify. + */ + fun downsample(stroke: InkStroke, epsilon: Float = 0.5f): InkStroke { + val collapsed = collapseIdenticalPositions(stroke.points) + val simplified = rdp(collapsed, epsilon) + return stroke.copy(points = simplified) + } + + private fun perpendicularDistance( + point: StrokePoint, + lineStart: StrokePoint, + lineEnd: StrokePoint + ): Float { + val dx = lineEnd.x - lineStart.x + val dy = lineEnd.y - lineStart.y + val lengthSq = dx * dx + dy * dy + if (lengthSq == 0f) { + val px = point.x - lineStart.x + val py = point.y - lineStart.y + return sqrt(px * px + py * py) + } + val num = abs(dy * point.x - dx * point.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x) + return num / sqrt(lengthSq) + } +} diff --git a/app/src/main/java/com/writer/recognition/TextRecognizer.kt b/app/src/main/java/com/writer/recognition/TextRecognizer.kt new file mode 100644 index 0000000..5236ccc --- /dev/null +++ b/app/src/main/java/com/writer/recognition/TextRecognizer.kt @@ -0,0 +1,35 @@ +package com.writer.recognition + +import com.writer.model.InkLine + +/** + * Abstraction for handwriting-to-text recognition engines. + * + * Implementations: + * - [GoogleMLKitTextRecognizer] — Google ML Kit Digital Ink (bundled model, works on all devices) + * - [OnyxHwrTextRecognizer] — Boox firmware's built-in MyScript engine via AIDL IPC + * (Onyx Boox devices only, significantly better accuracy) + */ +interface TextRecognizer { + + /** + * Initialize the recognition engine. Must be called before [recognizeLine]. + * May download models, bind to services, etc. + * + * @param languageTag BCP-47 language tag, e.g. "en-US" + * @throws Exception if initialization fails + */ + suspend fun initialize(languageTag: String) + + /** + * Recognize a single line of handwriting. + * + * @param line the strokes and bounding box for one line + * @param preContext up to 20 characters of preceding text for language model context + * @return recognized text (trimmed), or empty string if recognition fails + */ + suspend fun recognizeLine(line: InkLine, preContext: String = ""): String + + /** Release resources (models, service bindings, etc.). */ + fun close() +} diff --git a/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt b/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt new file mode 100644 index 0000000..800a064 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt @@ -0,0 +1,31 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build + +object TextRecognizerFactory { + fun create(context: Context): TextRecognizer { + if (isOnyxHwrAvailable(context)) return OnyxHwrTextRecognizer(context.applicationContext) + return GoogleMLKitTextRecognizer() + } + + @Suppress("DEPRECATION") + private fun isOnyxHwrAvailable(context: Context): Boolean { + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.resolveService( + intent, PackageManager.ResolveInfoFlags.of(0) + ) + } else { + context.packageManager.resolveService(intent, 0) + } != null + } +} diff --git a/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt b/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt index 42f1065..cc2c0b0 100644 --- a/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt @@ -1,7 +1,6 @@ package com.writer.ui.writing import android.content.Intent -import android.graphics.RectF import android.os.Bundle import android.util.Log import android.widget.TextView @@ -13,7 +12,7 @@ import com.writer.model.InkLine import com.writer.model.InkStroke import com.writer.model.minX import com.writer.model.maxX -import com.writer.recognition.HandwritingRecognizer +import com.writer.recognition.TextRecognizerFactory import com.writer.view.HandwritingNameInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -30,8 +29,7 @@ class SaveAsActivity : AppCompatActivity() { private lateinit var nameDisplay: TextView private lateinit var handwritingInput: HandwritingNameInput - private val recognizer = HandwritingRecognizer() - private var recognizerReady = false + private var recognizer: com.writer.recognition.TextRecognizer? = null private val allStrokes = mutableListOf() private var currentName = "" @@ -39,6 +37,18 @@ class SaveAsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_save_as) + lifecycleScope.launch { + val rec = TextRecognizerFactory.create(this@SaveAsActivity) + try { + rec.initialize("en-US") + recognizer = rec + } catch (e: Exception) { + rec.close() + Log.w(TAG, "Recognizer init failed", e) + Toast.makeText(this@SaveAsActivity, "Handwriting recognition unavailable", Toast.LENGTH_SHORT).show() + } + } + nameDisplay = findViewById(R.id.nameDisplay) handwritingInput = findViewById(R.id.handwritingInput) @@ -47,17 +57,6 @@ class SaveAsActivity : AppCompatActivity() { nameDisplay.text = currentName handwritingInput.placeholderText = currentName - // Initialize recognizer - lifecycleScope.launch { - try { - recognizer.initialize("en-US") - recognizerReady = true - } catch (e: Exception) { - Log.e(TAG, "Failed to init recognizer", e) - Toast.makeText(this@SaveAsActivity, "Recognition unavailable", Toast.LENGTH_SHORT).show() - } - } - // Handle stroke completion handwritingInput.onStrokeCompleted = { stroke -> onStrokeCompleted(stroke) @@ -122,7 +121,7 @@ class SaveAsActivity : AppCompatActivity() { } private fun recognizeAll() { - if (!recognizerReady) return + val rec = recognizer ?: return if (allStrokes.isEmpty()) { currentName = intent.getStringExtra(EXTRA_CURRENT_NAME) ?: "" nameDisplay.text = currentName @@ -136,7 +135,7 @@ class SaveAsActivity : AppCompatActivity() { lifecycleScope.launch { try { val text = withContext(Dispatchers.IO) { - recognizer.recognizeLine(line) + rec.recognizeLine(line) } currentName = text.trim() nameDisplay.text = currentName @@ -148,6 +147,6 @@ class SaveAsActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() - recognizer.close() + recognizer?.close() } } diff --git a/app/src/main/java/com/writer/ui/writing/TutorialContent.kt b/app/src/main/java/com/writer/ui/writing/TutorialContent.kt index 8916889..3df92bc 100644 --- a/app/src/main/java/com/writer/ui/writing/TutorialContent.kt +++ b/app/src/main/java/com/writer/ui/writing/TutorialContent.kt @@ -40,7 +40,6 @@ object TutorialContent { private val LINE_SPACING = HandwritingCanvasView.LINE_SPACING private val TOP_MARGIN = HandwritingCanvasView.TOP_MARGIN - private val GUTTER_WIDTH get() = HandwritingCanvasView.GUTTER_WIDTH private val textPaint = Paint().apply { typeface = Typeface.create("cursive", Typeface.NORMAL) @@ -48,7 +47,7 @@ object TutorialContent { } fun generate(canvasWidth: Int, canvasHeight: Int): TutorialData { - val writingWidth = canvasWidth - GUTTER_WIDTH + val writingWidth = canvasWidth.toFloat() val strokes = mutableListOf() val annotations = mutableListOf() @@ -103,15 +102,10 @@ object TutorialContent { TextAnnotation("Strike through to delete words", 700f, strikeY + 10f, red, 32f) ) - // --- Gutter scroll hint (top line, matching resize arrow style) --- + // --- Finger scroll hint --- val scrollHintY = lineTop(0) + LINE_SPACING * 0.4f - val scrollHintRight = writingWidth - 20f - val scrollHintLeft = writingWidth - 370f - annotations.add(makeLine(scrollHintLeft, scrollHintY, scrollHintRight, scrollHintY, blue, 4f)) - annotations.add(makeLine(scrollHintRight - 20f, scrollHintY - 12f, scrollHintRight, scrollHintY, blue, 4f)) - annotations.add(makeLine(scrollHintRight - 20f, scrollHintY + 12f, scrollHintRight, scrollHintY, blue, 4f)) textAnnotations.add( - TextAnnotation("Drag this gutter to scroll", scrollHintLeft.toFloat() - 10f, scrollHintY - 21f, blue, 34f) + TextAnnotation("Scroll with your finger", writingWidth - 200f, scrollHintY, blue, 34f) ) // --- Insert/delete line demo (right of eggs/bread area, +100px right) --- diff --git a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt index fb12b39..738a3fd 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt @@ -7,9 +7,10 @@ import android.util.Log import android.view.WindowInsets import android.view.WindowInsetsController import android.app.Activity -import android.widget.LinearLayout import android.view.Gravity import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout import android.widget.PopupWindow import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -19,11 +20,13 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import com.writer.R import com.writer.model.DocumentModel -import com.writer.recognition.HandwritingRecognizer +import com.writer.recognition.TextRecognizer +import com.writer.recognition.TextRecognizerFactory import com.writer.model.DocumentData import com.writer.storage.DocumentStorage import com.writer.view.HandwritingCanvasView import com.writer.view.RecognizedTextView +import com.writer.view.TouchFilter import kotlinx.coroutines.launch class WritingActivity : AppCompatActivity() { @@ -39,7 +42,7 @@ class WritingActivity : AppCompatActivity() { private lateinit var recognizedTextView: RecognizedTextView private lateinit var documentModel: DocumentModel - private lateinit var recognizer: HandwritingRecognizer + private lateinit var recognizer: TextRecognizer private var coordinator: WritingCoordinator? = null private lateinit var tutorialManager: TutorialManager @@ -59,7 +62,8 @@ class WritingActivity : AppCompatActivity() { ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { - val name = result.data?.getStringExtra(SaveAsActivity.EXTRA_RESULT_NAME) + val rawName = result.data?.getStringExtra(SaveAsActivity.EXTRA_RESULT_NAME) + val name = rawName?.let { DocumentStorage.headingToFileName(it) } if (!name.isNullOrBlank() && name != currentDocumentName) { val oldName = currentDocumentName currentDocumentName = name @@ -106,9 +110,14 @@ class WritingActivity : AppCompatActivity() { inkCanvas = findViewById(R.id.inkCanvas) recognizedTextView = findViewById(R.id.recognizedTextView) + val splitLayout = findViewById(R.id.splitLayout) + val splitDivider = findViewById(R.id.splitDivider) + + val touchFilter = TouchFilter() + inkCanvas.touchFilter = touchFilter + recognizedTextView.touchFilter = touchFilter documentModel = DocumentModel() - recognizer = HandwritingRecognizer() tutorialManager = TutorialManager( context = this, @@ -132,17 +141,39 @@ class WritingActivity : AppCompatActivity() { pendingRestore = DocumentStorage.load(this, currentDocumentName) restoreDocumentVisuals() + // Wire pen state from canvas to text view (for floating icon auto-hide) + inkCanvas.onPenStateChanged = { active -> + recognizedTextView.onPenStateChanged(active) + } + + // Text view scroll drives canvas scroll (complementary views) + recognizedTextView.onScroll = { dy -> + // dy > 0 = finger dragged down = see earlier content = scroll canvas up + val raw = inkCanvas.scrollOffsetY - dy + inkCanvas.scrollOffsetY = raw.coerceAtLeast(0f) + inkCanvas.drawToSurface() + inkCanvas.onManualScroll?.invoke() + } + recognizedTextView.onScrollEnd = { + inkCanvas.scrollOffsetY = inkCanvas.snapToLine(inkCanvas.scrollOffsetY) + inkCanvas.drawToSurface() + inkCanvas.onManualScroll?.invoke() + } + // Tap "I" logo to open menu recognizedTextView.onLogoTap = { showMenu() } + // Pick the best available recognizer synchronously (initialized later in coroutine) + recognizer = TextRecognizerFactory.create(this) + // Create coordinator early so cached text can be displayed before model loads startCoordinator() - // Capture default heights after initial layout, then wire up the gutter + // Capture default heights after initial layout, then wire up the divider drag recognizedTextView.post { defaultTextHeight = recognizedTextView.height defaultCanvasHeight = inkCanvas.height - setupTextGutter() + setupSplitDrag(splitLayout, splitDivider) // Restore cached text and scroll position immediately (no recognizer needed) restoreCoordinatorState() @@ -197,40 +228,28 @@ class WritingActivity : AppCompatActivity() { coordinator?.restoreState(data) } - private fun setupTextGutter() { - recognizedTextView.onGutterDrag = { delta -> + private fun setupSplitDrag(splitLayout: com.writer.view.SplitLayout, divider: View) { + splitLayout.dividerView = divider + splitLayout.onSplitDragStart = { inkCanvas.pauseRawDrawing() } + splitLayout.onSplitDragEnd = { inkCanvas.resumeRawDrawing() } + splitLayout.onSplitDrag = { delta -> val totalHeight = defaultTextHeight + defaultCanvasHeight val minTextHeight = (totalHeight * 0.25f).toInt() val maxOffset = (totalHeight - minTextHeight).toFloat() - if (delta > 0f && splitOffset >= maxOffset) { - // At max size, dragging down scrolls text content - val topPadding = 40f - val maxOverscroll = (recognizedTextView.totalTextHeight - recognizedTextView.height + topPadding).coerceAtLeast(0f) - inkCanvas.textOverscroll = (inkCanvas.textOverscroll + delta).coerceIn(0f, maxOverscroll) - coordinator?.onManualTextScroll() - } else if (delta < 0f && inkCanvas.textOverscroll > 0f) { - // Dragging back up — reduce overscroll first - inkCanvas.textOverscroll = (inkCanvas.textOverscroll + delta).coerceAtLeast(0f) - coordinator?.onManualTextScroll() - } else { - val totalHeight = defaultTextHeight + defaultCanvasHeight - val minTextHeight = (totalHeight * 0.25f).toInt() - val maxOffset = (totalHeight - minTextHeight).toFloat() - splitOffset = (splitOffset + delta).coerceIn(0f, maxOffset) - - val newTextHeight = defaultTextHeight + splitOffset.toInt() - val newCanvasHeight = defaultCanvasHeight - splitOffset.toInt() - - val textParams = recognizedTextView.layoutParams as LinearLayout.LayoutParams - textParams.height = newTextHeight - textParams.weight = 0f - recognizedTextView.layoutParams = textParams - - val canvasParams = inkCanvas.layoutParams as LinearLayout.LayoutParams - canvasParams.height = newCanvasHeight.coerceAtLeast(0) - canvasParams.weight = 0f - inkCanvas.layoutParams = canvasParams - } + splitOffset = (splitOffset + delta).coerceIn(0f, maxOffset) + + val newTextHeight = defaultTextHeight + splitOffset.toInt() + val newCanvasHeight = defaultCanvasHeight - splitOffset.toInt() + + val textParams = recognizedTextView.layoutParams as LinearLayout.LayoutParams + textParams.height = newTextHeight + textParams.weight = 0f + recognizedTextView.layoutParams = textParams + + val canvasParams = inkCanvas.layoutParams as LinearLayout.LayoutParams + canvasParams.height = newCanvasHeight.coerceAtLeast(0) + canvasParams.weight = 0f + inkCanvas.layoutParams = canvasParams } } @@ -465,11 +484,10 @@ class WritingActivity : AppCompatActivity() { Toast.makeText(this, "Tutorial reset — will show on next launch", Toast.LENGTH_SHORT).show() } - // Position to the left of the gutter, at the top of the text view - val gutterWidth = HandwritingCanvasView.GUTTER_WIDTH.toInt() + // Position to the left of the floating icon, at the top of the text view val loc = IntArray(2) recognizedTextView.getLocationOnScreen(loc) - val x = loc[0] + recognizedTextView.width - gutterWidth - popupWidth + val x = loc[0] + recognizedTextView.width - popupWidth val y = loc[1] popup.showAtLocation(recognizedTextView, Gravity.NO_GRAVITY, x, y) } diff --git a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt index 9c94daa..62d4f9f 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt @@ -4,13 +4,16 @@ import android.util.Log import com.writer.model.DiagramArea import com.writer.model.DocumentModel import com.writer.model.InkStroke +import com.writer.model.maxY import com.writer.model.shiftY -import com.writer.recognition.HandwritingRecognizer +import com.writer.recognition.TextRecognizer import com.writer.recognition.LineSegmenter import com.writer.recognition.StrokeClassifier import com.writer.model.DocumentData import com.writer.storage.SvgExporter +import com.writer.view.CanvasTheme import com.writer.view.HandwritingCanvasView +import com.writer.view.PreviewLayoutCalculator import com.writer.view.RecognizedTextView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,7 +24,7 @@ import kotlinx.coroutines.withContext class WritingCoordinator( private val documentModel: DocumentModel, - private val recognizer: HandwritingRecognizer, + private val recognizer: TextRecognizer, private val inkCanvas: HandwritingCanvasView, private val textView: RecognizedTextView, private val scope: CoroutineScope, @@ -32,7 +35,6 @@ class WritingCoordinator( // Scroll when writing passes this fraction of canvas height from top // 25% of canvas ≈ 50% of full screen (since canvas is 75% of screen) private const val SCROLL_THRESHOLD = 0.25f - private val GUTTER_WIDTH get() = HandwritingCanvasView.GUTTER_WIDTH // Delay before refreshing e-ink display after text view updates private const val TEXT_REFRESH_DELAY_MS = 500L } @@ -269,7 +271,7 @@ class WritingCoordinator( recognizingLines.remove(lineIndex) return null } - val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, inkCanvas.width - GUTTER_WIDTH) + val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, inkCanvas.width.toFloat()) if (strokes.isEmpty()) { recognizingLines.remove(lineIndex) return null @@ -411,15 +413,15 @@ class WritingCoordinator( private fun displayHiddenLines() { val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - val currentlyHidden = strokesByLine.keys.filter { lineIdx -> - val lineBottom = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING - lineBottom <= inkCanvas.scrollOffsetY - }.toSet() + val currentlyHidden = PreviewLayoutCalculator.currentlyHiddenLines( + strokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) - val notYetVisible = strokesByLine.keys.filter { lineIdx -> - val lineMid = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING / 2f - lineMid <= inkCanvas.scrollOffsetY - }.toSet() + val notYetVisible = PreviewLayoutCalculator.notYetVisibleLines( + strokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) everHiddenLines.addAll(currentlyHidden) everHiddenLines.retainAll(strokesByLine.keys) @@ -427,32 +429,19 @@ class WritingCoordinator( updateTextView(notYetVisible) updateTextScrollOffset() - val uncached = everHiddenLines.filter { !lineTextCache.containsKey(it) && !isDiagramLine(it) } + val uncached = everHiddenLines.filter { !lineTextCache.containsKey(it) && !isDiagramLine(it) && !recognizingLines.contains(it) } if (uncached.isNotEmpty()) { + for (lineIdx in uncached) { + recognizingLines.add(lineIdx) + } scope.launch { for (lineIdx in uncached) { - if (lineTextCache.containsKey(lineIdx)) continue - if (isDiagramLine(lineIdx)) continue - try { - val allStrokes = strokesByLine[lineIdx] ?: continue - val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, inkCanvas.width - GUTTER_WIDTH) - if (strokes.isEmpty()) continue - val line = lineSegmenter.buildInkLine(strokes, lineIdx) - val preContext = buildPreContext(lineIdx) - val text = withContext(Dispatchers.IO) { - recognizer.recognizeLine(line, preContext) - } - lineTextCache[lineIdx] = text.trim() - Log.d(TAG, "On-scroll recognized line $lineIdx: \"${text.trim()}\"") - } catch (e: Exception) { - Log.e(TAG, "Recognition failed for line $lineIdx", e) - lineTextCache[lineIdx] = "[?]" - } + doRecognizeLine(lineIdx) } - val stillNotVisible = strokesByLine.keys.filter { lineIdx -> - val lineMid = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING / 2f - lineMid <= inkCanvas.scrollOffsetY - }.toSet() + val stillNotVisible = PreviewLayoutCalculator.notYetVisibleLines( + strokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) updateTextView(stillNotVisible) } } @@ -467,14 +456,16 @@ class WritingCoordinator( val strokes: List, val canvasWidth: Float, val heightPx: Float, - val offsetY: Float + val offsetY: Float, + /** How much of the diagram's height is scrolled off the canvas (for partial rendering). */ + val visibleHeightPx: Float = heightPx ) private fun updateTextView(currentlyHidden: Set) { val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - val writingWidth = inkCanvas.width - GUTTER_WIDTH + val writingWidth = inkCanvas.width.toFloat() - val classifiedLines = everHiddenLines.sorted().filter { !isDiagramLine(it) }.mapNotNull { lineIdx -> + val classifiedLines = currentlyHidden.sorted().filter { !isDiagramLine(it) }.mapNotNull { lineIdx -> paragraphBuilder.classifyLine(lineIdx, lineTextCache[lineIdx], strokesByLine[lineIdx], writingWidth) } @@ -492,72 +483,53 @@ class WritingCoordinator( } } - // Build diagram displays for fully-hidden diagram areas - val diagrams = documentModel.diagramAreas.filter { area -> - val areaBottom = lineSegmenter.getLineY(area.endLineIndex + 1) - areaBottom <= inkCanvas.scrollOffsetY - }.map { area -> + // Build diagram displays — include as soon as any part scrolls off the canvas + val strokeMaxYByArea = documentModel.diagramAreas.associate { area -> val areaStrokes = documentModel.activeStrokes.filter { stroke -> - val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) - area.containsLine(strokeLine) + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) } + area.startLineIndex to (if (areaStrokes.isNotEmpty()) areaStrokes.maxOf { it.maxY } else null) + }.filterValues { it != null }.mapValues { it.value!! } + + val visibilities = PreviewLayoutCalculator.diagramVisibilities( + areas = documentModel.diagramAreas, + scrollOffsetY = inkCanvas.scrollOffsetY, + topMargin = HandwritingCanvasView.TOP_MARGIN, + lineSpacing = HandwritingCanvasView.LINE_SPACING, + strokeMaxYByArea = strokeMaxYByArea, + strokeWidthPadding = CanvasTheme.DEFAULT_STROKE_WIDTH + ) + + val areaByStartLine = documentModel.diagramAreas.associateBy { it.startLineIndex } + val diagrams = visibilities.map { vis -> + val area = areaByStartLine[vis.startLineIndex] + val areaStrokes = if (area != null) { + documentModel.activeStrokes.filter { stroke -> + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) + } + } else emptyList() DiagramDisplay( - startLineIndex = area.startLineIndex, + startLineIndex = vis.startLineIndex, strokes = areaStrokes, canvasWidth = writingWidth, - heightPx = area.heightInLines * HandwritingCanvasView.LINE_SPACING, - offsetY = lineSegmenter.getLineY(area.startLineIndex) + heightPx = vis.fullHeight, + offsetY = vis.areaTop, + visibleHeightPx = vis.visibleHeight ) } textView.setContent(paragraphs, diagrams) } - /** Called when the text view is scrolled via its gutter overscroll. */ + /** Called when the text view is scrolled via overscroll. */ fun onManualTextScroll() { textView.textContentScroll = inkCanvas.textOverscroll textView.invalidate() } private fun updateTextScrollOffset() { - val lineHeights = textView.writtenLineHeights - if (lineHeights.isEmpty()) { - textView.textScrollOffset = 0f - return - } - - var offset = 0f - - for (i in lineHeights.indices.reversed()) { - val (lineIdx, textHeight) = lineHeights[i] - - if (textHeight <= 0f) continue - - val lineTop = lineSegmenter.getLineY(lineIdx) - if (lineTop < inkCanvas.scrollOffsetY) { - break - } - - val drivingLine = lineIdx - 1 - if (drivingLine < 0) { - offset += textHeight - continue - } - - val drivingLineBottom = lineSegmenter.getLineY(drivingLine) + HandwritingCanvasView.LINE_SPACING - val fraction = ((drivingLineBottom - inkCanvas.scrollOffsetY) / HandwritingCanvasView.LINE_SPACING) - .coerceIn(0f, 1f) - - if (fraction >= 1f) { - offset += textHeight - continue - } - - offset += fraction * textHeight - break - } - - textView.textScrollOffset = offset + // Content is flush with the divider — preview and canvas are complementary + textView.textScrollOffset = 0f } // --- Markdown export --- @@ -566,7 +538,7 @@ class WritingCoordinator( if (lineTextCache.isEmpty() && documentModel.diagramAreas.isEmpty()) return "" val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - val writingWidth = inkCanvas.width - GUTTER_WIDTH + val writingWidth = inkCanvas.width.toFloat() val classifiedLines = lineTextCache.keys.sorted().filter { !isDiagramLine(it) }.mapNotNull { lineIdx -> paragraphBuilder.classifyLine(lineIdx, lineTextCache[lineIdx], strokesByLine[lineIdx], writingWidth) diff --git a/app/src/main/java/com/writer/view/CanvasTheme.kt b/app/src/main/java/com/writer/view/CanvasTheme.kt index 6206fa9..4f239f4 100644 --- a/app/src/main/java/com/writer/view/CanvasTheme.kt +++ b/app/src/main/java/com/writer/view/CanvasTheme.kt @@ -13,7 +13,6 @@ import com.writer.model.InkStroke object CanvasTheme { const val DEFAULT_STROKE_WIDTH = 5f val LINE_COLOR: Int = Color.parseColor("#AAAAAA") - val GUTTER_FILL_COLOR: Int = Color.parseColor("#DDDDDD") fun newStrokePaint() = Paint().apply { color = Color.BLACK @@ -30,17 +29,6 @@ object CanvasTheme { style = Paint.Style.STROKE } - fun newGutterFillPaint() = Paint().apply { - color = GUTTER_FILL_COLOR - style = Paint.Style.FILL - } - - fun newGutterLinePaint() = Paint().apply { - color = LINE_COLOR - strokeWidth = 1f - style = Paint.Style.STROKE - } - val DIAGRAM_BORDER_COLOR: Int = Color.parseColor("#555555") fun newDiagramBorderPaint() = Paint().apply { diff --git a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt index 4f13e06..72d2c1f 100644 --- a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt +++ b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt @@ -37,14 +37,12 @@ class HandwritingCanvasView @JvmOverloads constructor( companion object { private const val TAG = "HandwritingCanvas" - // Line spacing, top margin and gutter width are DPI-scaled via ScreenMetrics. + // Line spacing and top margin are DPI-scaled via ScreenMetrics. val LINE_SPACING get() = ScreenMetrics.lineSpacing // Idle timeout before checking scroll condition (ms) private const val IDLE_TIMEOUT_MS = 2000L // Top margin before the first line val TOP_MARGIN get() = ScreenMetrics.topMargin - // Width of the scroll gutter on the right edge - val GUTTER_WIDTH get() = ScreenMetrics.gutterWidth // Line-drag gesture: vertical span to activate (either direction) private const val LINE_DRAG_MIN_SPANS = 1f // Line-drag gesture: max horizontal drift ratio during activation @@ -73,8 +71,6 @@ class HandwritingCanvasView @JvmOverloads constructor( private val strokePaint = CanvasTheme.newStrokePaint() private val linePaint = CanvasTheme.newLinePaint() - private val gutterPaint = CanvasTheme.newGutterFillPaint() - private val gutterLinePaint = CanvasTheme.newGutterLinePaint() private val diagramBorderPaint = CanvasTheme.newDiagramBorderPaint() private val annotationPaint = Paint().apply { @@ -102,6 +98,9 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Called when manual scrolling changes the offset. */ var onManualScroll: (() -> Unit)? = null + /** Called when pen state changes: true = pen down (writing), false = pen lifted. */ + var onPenStateChanged: ((Boolean) -> Unit)? = null + // Line-drag gesture callbacks var onLineDragStart: ((anchorLine: Int) -> Unit)? = null var onLineDragStep: ((shiftLines: Int) -> Unit)? = null @@ -123,10 +122,6 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Extra scroll past the top of the document, for scrolling the text view. */ var textOverscroll: Float = 0f - // Gutter scrolling state - private var isGutterDragging = false - private var gutterDragLastY = 0f - // Line-drag gesture state private var lineDragActive = false private var lineDragStartScreenY = 0f @@ -149,6 +144,13 @@ class HandwritingCanvasView @JvmOverloads constructor( // Whether we've temporarily changed the Onyx SDK limit rect for a diagram stroke private var diagramLimitActive = false + /** Shared palm-rejection filter, set by WritingActivity. */ + var touchFilter: TouchFilter? = null + + // Finger scroll state + private var fingerScrollActive = false + private var fingerScrollLastY = 0f + private val idleRunnable = Runnable { onIdleTimeout?.invoke() } private var useOnyxSdk = false @@ -172,6 +174,8 @@ class HandwritingCanvasView @JvmOverloads constructor( private val onyxCallback = object : RawInputCallback() { override fun onBeginRawDrawing(b: Boolean, tp: TouchPoint) { + touchFilter?.penActive = true + onPenStateChanged?.invoke(true) handler.removeCallbacks(idleRunnable) currentStrokePoints.clear() val docPt = tp.toDocStrokePoint() @@ -216,6 +220,13 @@ class HandwritingCanvasView @JvmOverloads constructor( } override fun onEndRawDrawing(b: Boolean, tp: TouchPoint) { + if (!lineDragActive && !diagramInsertActive && !undoScrubActive) { + touchFilter?.let { + it.penActive = false + it.penUpTimestamp = android.os.SystemClock.uptimeMillis() + } + onPenStateChanged?.invoke(false) + } Log.d(TAG, "onEndRawDrawing: ${currentStrokePoints.size} points, lineDrag=$lineDragActive, diagramInsert=$diagramInsertActive, undoReady=$undoGestureReady, undoScrub=$undoScrubActive") if (lineDragActive || diagramInsertActive || undoScrubActive) { // SDK fires this when disabled mid-stroke (buffer dump). @@ -276,7 +287,6 @@ class HandwritingCanvasView @JvmOverloads constructor( try { val limit = Rect() getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() touchHelper?.setLimitRect(limit, emptyList()) } catch (e: Exception) { Log.w(TAG, "Error updating limit rect: ${e.message}") @@ -301,7 +311,6 @@ class HandwritingCanvasView @JvmOverloads constructor( try { val limit = Rect() getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() touchHelper = TouchHelper.create(this, onyxCallback) touchHelper?.setStrokeWidth(CanvasTheme.DEFAULT_STROKE_WIDTH) @@ -325,20 +334,10 @@ class HandwritingCanvasView @JvmOverloads constructor( override fun onTouchEvent(event: MotionEvent): Boolean { val toolType = event.getToolType(0) - // Reject all finger/palm touches, but cancel idle timer + // Finger touches: filter through palm rejection, allow vertical scroll if (toolType == MotionEvent.TOOL_TYPE_FINGER) { handler.removeCallbacks(idleRunnable) - return false - } - - // If already in a gutter drag, keep handling as gutter even if pen leaves the area - if (isGutterDragging) { - return handleGutterTouch(event) - } - - // Stylus/mouse in gutter area → scroll drag - if (event.x >= width - GUTTER_WIDTH) { - return handleGutterTouch(event) + return handleFingerTouch(event) } // If an interactive gesture is active, we've disabled the SDK and handle here @@ -352,7 +351,7 @@ class HandwritingCanvasView @JvmOverloads constructor( return handleUndoTouch(event) } - // In tutorial mode, block all writing input but allow gutter (handled above) + // In tutorial mode, block all writing input if (tutorialMode) return false // If using Onyx SDK, pen input in the canvas area is handled by SDK callbacks @@ -374,6 +373,8 @@ class HandwritingCanvasView @JvmOverloads constructor( when (event.action) { MotionEvent.ACTION_DOWN -> { + touchFilter?.penActive = true + onPenStateChanged?.invoke(true) handler.removeCallbacks(idleRunnable) currentStrokePoints.clear() currentPath.reset() @@ -412,6 +413,11 @@ class HandwritingCanvasView @JvmOverloads constructor( return true } MotionEvent.ACTION_UP -> { + touchFilter?.let { + it.penActive = false + it.penUpTimestamp = android.os.SystemClock.uptimeMillis() + } + onPenStateChanged?.invoke(false) if (lineDragActive) { endLineDrag() return true @@ -440,21 +446,52 @@ class HandwritingCanvasView @JvmOverloads constructor( return super.onTouchEvent(event) } - private fun handleGutterTouch(event: MotionEvent): Boolean { + /** + * Handle filtered finger touches on the canvas. Only vertical scrolling is + * allowed — no taps (avoids accidental palm taps). + */ + private fun handleFingerTouch(event: MotionEvent): Boolean { + val tf = touchFilter ?: return false + val touchMinorDp = event.touchMinor / ScreenMetrics.density + when (event.action) { MotionEvent.ACTION_DOWN -> { - isGutterDragging = true - gutterDragLastY = event.y - handler.removeCallbacks(idleRunnable) + if (tf.evaluateDown( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + ) == TouchFilter.Decision.REJECT + ) { + fingerScrollActive = false + return false + } + fingerScrollLastY = event.y + fingerScrollActive = true pauseRawDrawing() return true } MotionEvent.ACTION_MOVE -> { - if (!isGutterDragging) return false - val dy = gutterDragLastY - event.y // drag up = positive = scroll down - gutterDragLastY = event.y + if (!fingerScrollActive) return false + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = true, + ) == TouchFilter.Decision.REJECT + ) { + // Cancel this finger gesture + fingerScrollActive = false + if (!tutorialMode) resumeRawDrawing() + return false + } + if (!tf.hasMovedPastSlop()) return true // wait for intentional drag + val dy = fingerScrollLastY - event.y // drag up = scroll down + fingerScrollLastY = event.y if (textOverscroll > 0f && dy > 0f) { - // Scrolling back down — reduce text overscroll first textOverscroll = (textOverscroll - dy).coerceAtLeast(0f) } else { val raw = scrollOffsetY + dy @@ -470,8 +507,8 @@ class HandwritingCanvasView @JvmOverloads constructor( return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (!isGutterDragging) return false - isGutterDragging = false + if (!fingerScrollActive) return false + fingerScrollActive = false if (textOverscroll == 0f) { scrollOffsetY = snapToLine(scrollOffsetY) } @@ -522,7 +559,7 @@ class HandwritingCanvasView @JvmOverloads constructor( try { val limit = Rect() limit.left = 0 - limit.right = (width - GUTTER_WIDTH).toInt() + limit.right = width limit.top = (topY - scrollOffsetY).toInt().coerceAtLeast(0) limit.bottom = (bottomY - scrollOffsetY).toInt().coerceAtMost(height) touchHelper?.setLimitRect(limit, emptyList()) @@ -532,13 +569,12 @@ class HandwritingCanvasView @JvmOverloads constructor( } } - /** Restore Onyx SDK drawing area to the full canvas minus gutter. */ + /** Restore Onyx SDK drawing area to the full canvas. */ private fun restoreLimitRect() { if (!diagramLimitActive || !useOnyxSdk) return try { val limit = Rect() getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() touchHelper?.setLimitRect(limit, emptyList()) diagramLimitActive = false } catch (e: Exception) { @@ -878,6 +914,7 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Common cleanup after any interactive gesture (line-drag or undo scrub). */ private fun finishInteractiveGesture() { + onPenStateChanged?.invoke(false) drawToSurface() if (useOnyxSdk) { try { @@ -904,7 +941,7 @@ class HandwritingCanvasView @JvmOverloads constructor( // Clear background canvas.drawColor(Color.WHITE) - val gutterLeft = width - GUTTER_WIDTH + val canvasRight = width.toFloat() // Apply scroll offset canvas.save() @@ -919,9 +956,9 @@ class HandwritingCanvasView @JvmOverloads constructor( val isBottomBorder = diagramAreas.any { lineIdx == it.endLineIndex + 1 } val isInterior = diagramAreas.any { lineIdx > it.startLineIndex && lineIdx <= it.endLineIndex } if (isTopBorder || isBottomBorder) { - canvas.drawLine(0f, lineY, gutterLeft, lineY, diagramBorderPaint) + canvas.drawLine(0f, lineY, canvasRight, lineY, diagramBorderPaint) } else if (!isInterior) { - canvas.drawLine(0f, lineY, gutterLeft, lineY, linePaint) + canvas.drawLine(0f, lineY, canvasRight, lineY, linePaint) } lineY += LINE_SPACING } @@ -942,11 +979,7 @@ class HandwritingCanvasView @JvmOverloads constructor( canvas.restore() - // Draw gutter (in screen space) - canvas.drawRect(gutterLeft, 0f, width.toFloat(), height.toFloat(), gutterPaint) - canvas.drawLine(gutterLeft, 0f, gutterLeft, height.toFloat(), gutterLinePaint) - - // Draw tutorial annotations on top of everything (including gutter) + // Draw tutorial annotations on top of everything if (annotationStrokes.isNotEmpty() || textAnnotations.isNotEmpty()) { canvas.save() canvas.translate(0f, -scrollOffsetY) diff --git a/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt b/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt new file mode 100644 index 0000000..d8e780c --- /dev/null +++ b/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt @@ -0,0 +1,165 @@ +package com.writer.view + +import com.writer.model.DiagramArea + +/** + * Pure-logic calculator for the complementary preview layout. + * + * Determines which lines are currently hidden (scrolled off the canvas), + * which diagram areas are visible in the preview, and how to size/clip + * diagram render items so the preview is flush with the canvas boundary. + * + * All methods are side-effect-free and depend only on their parameters, + * making them easy to test on JVM without Android framework dependencies. + */ +object PreviewLayoutCalculator { + + // ── Line visibility ───────────────────────────────────────────────── + + /** + * Returns the set of line indices whose bottom edge is at or above [scrollOffsetY]. + * These lines are fully scrolled off the canvas and should appear in the preview. + */ + fun currentlyHiddenLines( + lineIndices: Set, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float + ): Set = lineIndices.filter { lineIdx -> + val lineBottom = topMargin + lineIdx * lineSpacing + lineSpacing + lineBottom <= scrollOffsetY + }.toSet() + + /** + * Returns the set of line indices whose midpoint is at or above [scrollOffsetY]. + * Used for dimming: a line is "not yet visible" once its midpoint has scrolled off. + */ + fun notYetVisibleLines( + lineIndices: Set, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float + ): Set = lineIndices.filter { lineIdx -> + val lineMid = topMargin + lineIdx * lineSpacing + lineSpacing / 2f + lineMid <= scrollOffsetY + }.toSet() + + // ── Diagram visibility ────────────────────────────────────────────── + + /** Result of computing diagram visibility for the preview. */ + data class DiagramVisibility( + val startLineIndex: Int, + /** Full area height in document pixels. */ + val fullHeight: Float, + /** Document-space Y of the area top. */ + val areaTop: Float, + /** How much of the diagram is scrolled off the canvas (capped at stroke bounds). */ + val visibleHeight: Float, + /** Whether this is a partial render (not all strokes visible yet). */ + val isPartial: Boolean + ) + + /** + * Determine which diagram areas should appear in the preview and how much + * of each is visible. A diagram appears as soon as its top edge scrolls off + * the canvas. The visible height is capped at the actual stroke bottom + * (not the full area height) to avoid empty-line gaps. + */ + fun diagramVisibilities( + areas: List, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float, + /** For each area, the max Y of its strokes in document space (null if no strokes). */ + strokeMaxYByArea: Map, + strokeWidthPadding: Float + ): List { + return areas.mapNotNull { area -> + val areaTop = topMargin + area.startLineIndex * lineSpacing + if (areaTop >= scrollOffsetY) return@mapNotNull null // not scrolled off yet + + val fullHeight = area.heightInLines * lineSpacing + val scrolledOff = (scrollOffsetY - areaTop).coerceIn(0f, fullHeight) + + val strokeMaxY = strokeMaxYByArea[area.startLineIndex] + val strokeBottom = if (strokeMaxY != null) { + (strokeMaxY - areaTop + strokeWidthPadding).coerceAtMost(fullHeight) + } else { + fullHeight + } + + val visibleHeight = scrolledOff.coerceAtMost(strokeBottom) + + DiagramVisibility( + startLineIndex = area.startLineIndex, + fullHeight = fullHeight, + areaTop = areaTop, + visibleHeight = visibleHeight, + isPartial = visibleHeight < fullHeight + ) + } + } + + // ── Render item layout ────────────────────────────────────────────── + + /** Computed layout metrics for a diagram render item. */ + data class DiagramRenderMetrics( + val scale: Float, + val fullRenderedHeight: Float, + val renderedHeight: Float, + val isPartial: Boolean + ) + + /** + * Compute the render metrics for a diagram in the preview. + * + * @param visibleHeight How much of the diagram is scrolled off (document px) + * @param fullHeight Full diagram area height (document px) + * @param canvasWidth Width of the canvas (source coordinate space) + * @param textViewWidth Width of the text view (target coordinate space) + * @param paragraphSpacing Spacing added between render items + */ + fun diagramRenderMetrics( + visibleHeight: Float, + fullHeight: Float, + canvasWidth: Float, + textViewWidth: Float, + paragraphSpacing: Float + ): DiagramRenderMetrics { + val scale = if (canvasWidth > 0f) textViewWidth / canvasWidth else 1f + val isPartial = visibleHeight < fullHeight + val fullRenderedHeight = fullHeight * scale + paragraphSpacing + val renderedHeight = visibleHeight * scale + if (isPartial) 0f else paragraphSpacing + + return DiagramRenderMetrics( + scale = scale, + fullRenderedHeight = fullRenderedHeight, + renderedHeight = renderedHeight, + isPartial = isPartial + ) + } + + /** + * Strip trailing spacing from the last item height so the preview content + * is flush with the divider. Returns the trimmed height. + * + * @param heightPx The item's current rendered height + * @param fullHeightPx The item's full rendered height (including spacing) + * @param paragraphSpacing Spacing to remove + * @param isText True if text item, false if diagram + * @param textLayoutHeight The text StaticLayout height (only used for text items) + */ + fun trimLastItemHeight( + heightPx: Float, + fullHeightPx: Float, + paragraphSpacing: Float, + isText: Boolean, + textLayoutHeight: Float = 0f + ): Float { + return if (isText) { + textLayoutHeight + } else { + heightPx.coerceAtMost(fullHeightPx - paragraphSpacing) + } + } +} diff --git a/app/src/main/java/com/writer/view/RecognizedTextView.kt b/app/src/main/java/com/writer/view/RecognizedTextView.kt index a9741cb..533a3a3 100644 --- a/app/src/main/java/com/writer/view/RecognizedTextView.kt +++ b/app/src/main/java/com/writer/view/RecognizedTextView.kt @@ -27,7 +27,7 @@ import com.writer.ui.writing.WritingCoordinator.TextSegment * Individual line segments within a paragraph can be dimmed independently * using colored spans. * - * Includes a right-side gutter with a "I" logo and resize drag handling. + * Includes a floating "I" logo icon that auto-hides during writing. */ class RecognizedTextView @JvmOverloads constructor( context: Context, @@ -36,7 +36,6 @@ class RecognizedTextView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr) { companion object { - private val GUTTER_WIDTH get() = ScreenMetrics.gutterWidth private val HORIZONTAL_PADDING get() = ScreenMetrics.dp(21f) private val PARAGRAPH_SPACING get() = ScreenMetrics.dp(12f) private val LIST_ITEM_SPACING get() = ScreenMetrics.dp(3f) @@ -45,7 +44,6 @@ class RecognizedTextView @JvmOverloads constructor( private val BULLET_HANG_INDENT get() = ScreenMetrics.dp(54f).toInt() private const val BULLET_PREFIX = "\u2022 " private val HEADING_SPACING_AFTER get() = ScreenMetrics.dp(6f) - private val BOTTOM_PADDING get() = ScreenMetrics.dp(5f) } private val textPaint = TextPaint().apply { @@ -56,9 +54,6 @@ class RecognizedTextView @JvmOverloads constructor( private val dimmedColor = CanvasTheme.LINE_COLOR - private val gutterPaint = CanvasTheme.newGutterFillPaint() - private val gutterLinePaint = CanvasTheme.newGutterLinePaint() - private val logoPaint = TextPaint().apply { color = Color.BLACK textSize = ScreenMetrics.textLogo @@ -137,6 +132,7 @@ class RecognizedTextView @JvmOverloads constructor( val strokes: List, val scale: Float, val offsetY: Float, + val fullHeightPx: Float, override val heightPx: Float, val lineIndex: Int ) : RenderItem() @@ -169,8 +165,11 @@ class RecognizedTextView @JvmOverloads constructor( /** Pixel offset to scroll text content upward (for viewing earlier text). */ var textContentScroll: Float = 0f - /** Called when the user drags the gutter. Delta is positive = drag down. */ - var onGutterDrag: ((Float) -> Unit)? = null + /** Called when the user scrolls the text view. Delta is in pixels (positive = finger drag down). */ + var onScroll: ((Float) -> Unit)? = null + + /** Called when a scroll gesture ends (finger lifted). */ + var onScrollEnd: (() -> Unit)? = null /** Called when the user taps the "I" logo. */ var onLogoTap: (() -> Unit)? = null @@ -192,17 +191,39 @@ class RecognizedTextView @JvmOverloads constructor( invalidate() } - // Gutter drag state - private var isGutterDragging = false - private var gutterDragLastY = 0f - private var gutterDragStartY = 0f - private var gutterDragMoved = false + // Floating icon visibility (auto-hides during writing) + private var iconVisible = true + private val iconSize get() = ScreenMetrics.dp(56f) + private val iconShowRunnable = Runnable { + iconVisible = true + invalidate() + } // Text tap tracking private var textTapDownX = 0f private var textTapDownY = 0f private var textTapTracking = false + /** Shared palm-rejection filter, set by WritingActivity. */ + var touchFilter: TouchFilter? = null + + // Finger scroll state + private var fingerScrollActive = false + private var fingerScrollLastY = 0f + + /** Called by WritingActivity when pen state changes on the canvas. */ + fun onPenStateChanged(active: Boolean) { + handler.removeCallbacks(iconShowRunnable) + if (active) { + if (iconVisible) { + iconVisible = false + invalidate() + } + } else { + handler.postDelayed(iconShowRunnable, 300L) + } + } + fun setParagraphs(paragraphs: List>) { setContent(paragraphs, emptyList()) } @@ -214,7 +235,7 @@ class RecognizedTextView @JvmOverloads constructor( } private fun rebuildRenderItems(paragraphs: List>, diagrams: List) { - val availableWidth = (width - HORIZONTAL_PADDING - HandwritingCanvasView.GUTTER_WIDTH).toInt() + val availableWidth = (width - 2 * HORIZONTAL_PADDING).toInt() if (availableWidth <= 0) return data class Indexed(val lineIndex: Int, val item: RenderItem, val lineHeights: List>) @@ -316,20 +337,46 @@ class RecognizedTextView @JvmOverloads constructor( // Build diagram render items (full width, no text padding) val diagramItems = diagrams.map { diagram -> - val fullWidth = width - HandwritingCanvasView.GUTTER_WIDTH - val scale = if (diagram.canvasWidth > 0f) fullWidth / diagram.canvasWidth else 1f - val renderedHeight = diagram.heightPx * scale + PARAGRAPH_SPACING + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = diagram.visibleHeightPx, + fullHeight = diagram.heightPx, + canvasWidth = diagram.canvasWidth, + textViewWidth = width.toFloat(), + paragraphSpacing = PARAGRAPH_SPACING + ) Indexed( lineIndex = diagram.startLineIndex, - item = DiagramRenderItem(diagram.strokes, scale, diagram.offsetY, renderedHeight, diagram.startLineIndex), - lineHeights = listOf(Pair(diagram.startLineIndex, renderedHeight)) + item = DiagramRenderItem(diagram.strokes, metrics.scale, diagram.offsetY, metrics.fullRenderedHeight, metrics.renderedHeight, diagram.startLineIndex), + lineHeights = listOf(Pair(diagram.startLineIndex, metrics.renderedHeight)) ) } // Merge and sort by line index val allItems = (textItems + diagramItems).sortedBy { it.lineIndex } - renderItems = allItems.map { it.item } + // Strip trailing spacing from the last item so content is flush with the divider + val items = allItems.map { it.item }.toMutableList() + if (items.isNotEmpty()) { + val last = items.last() + when (last) { + is TextRenderItem -> { + val trimmedHeight = PreviewLayoutCalculator.trimLastItemHeight( + last.heightPx, last.heightPx, PARAGRAPH_SPACING, + isText = true, textLayoutHeight = last.layout.height.toFloat() + ) + items[items.lastIndex] = last.copy(heightPx = trimmedHeight) + } + is DiagramRenderItem -> { + val trimmedHeight = PreviewLayoutCalculator.trimLastItemHeight( + last.heightPx, last.fullHeightPx, PARAGRAPH_SPACING, + isText = false + ) + items[items.lastIndex] = last.copy(heightPx = trimmedHeight) + } + } + } + + renderItems = items paragraphHeights = renderItems.map { it.heightPx } writtenLineHeights = allItems.flatMap { it.lineHeights } totalTextHeight = paragraphHeights.sum().toInt() @@ -340,23 +387,23 @@ class RecognizedTextView @JvmOverloads constructor( // Can't rebuild without paragraph data; next setParagraphs call will handle it } + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + handler.removeCallbacks(iconShowRunnable) + } + // --- Touch handling --- override fun onTouchEvent(event: MotionEvent): Boolean { val toolType = event.getToolType(0) - // Reject finger/palm touches + // Finger touches: filter through palm rejection, allow taps and scroll if (toolType == MotionEvent.TOOL_TYPE_FINGER) { - return false - } - - // If already in a gutter drag, keep handling even if pen leaves gutter area - if (isGutterDragging) { - return handleGutterTouch(event) + return handleFingerTouch(event) } // Tutorial: close button tap at top of text area - if (tutorialMode && event.x < width - HandwritingCanvasView.GUTTER_WIDTH && event.y < closeButtonHeight) { + if (tutorialMode && event.y < closeButtonHeight) { if (event.action == MotionEvent.ACTION_DOWN) { return true } @@ -366,9 +413,13 @@ class RecognizedTextView @JvmOverloads constructor( } } - // Stylus/mouse in gutter area → resize drag - if (event.x >= width - HandwritingCanvasView.GUTTER_WIDTH) { - return handleGutterTouch(event) + // Floating icon tap detection + if (iconVisible && event.action == MotionEvent.ACTION_DOWN && isInIconArea(event.x, event.y)) { + return true + } + if (iconVisible && event.action == MotionEvent.ACTION_UP && isInIconArea(event.x, event.y)) { + onLogoTap?.invoke() + return true } // Stylus/mouse in text area → detect taps to scroll canvas to that line @@ -406,42 +457,144 @@ class RecognizedTextView @JvmOverloads constructor( return super.onTouchEvent(event) } - private fun handleGutterTouch(event: MotionEvent): Boolean { + /** + * Handle filtered finger touches on the text view. + * Allows: logo tap, text tap, text body scroll. + */ + private fun handleFingerTouch(event: MotionEvent): Boolean { + val tf = touchFilter ?: return false + val touchMinorDp = event.touchMinor / ScreenMetrics.density + + // If already in a finger scroll, keep handling + if (fingerScrollActive) { + when (event.action) { + MotionEvent.ACTION_MOVE -> { + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = false, + ) == TouchFilter.Decision.REJECT + ) { + fingerScrollActive = false + return false + } + val dy = event.y - fingerScrollLastY + fingerScrollLastY = event.y + onScroll?.invoke(dy) + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + fingerScrollActive = false + onScrollEnd?.invoke() + return true + } + } + return true + } + when (event.action) { MotionEvent.ACTION_DOWN -> { - isGutterDragging = true - gutterDragLastY = event.y - gutterDragStartY = event.y - gutterDragMoved = false + if (tf.evaluateDown( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + ) == TouchFilter.Decision.REJECT + ) { + return false + } + + // Floating icon tap + if (iconVisible && isInIconArea(event.x, event.y)) { + return true + } + + // Tutorial close button + if (tutorialMode && event.y < closeButtonHeight) { + return true + } + + // Track for tap or scroll on text body + if (!tutorialMode) { + textTapDownX = event.x + textTapDownY = event.y + textTapTracking = true + fingerScrollLastY = event.y + } return true } MotionEvent.ACTION_MOVE -> { - if (!isGutterDragging) return false - val dy = event.y - gutterDragLastY // drag down = positive - gutterDragLastY = event.y - if (Math.abs(event.y - gutterDragStartY) > 20f) gutterDragMoved = true - onGutterDrag?.invoke(dy) + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = false, + ) == TouchFilter.Decision.REJECT + ) { + textTapTracking = false + fingerScrollActive = false + return false + } + if (textTapTracking) { + val dx = event.x - textTapDownX + val dy = event.y - textTapDownY + if (dx * dx + dy * dy > 400f) { + textTapTracking = false + // Transition to finger scroll + fingerScrollActive = true + fingerScrollLastY = event.y + } + } + if (fingerScrollActive) { + val dy = event.y - fingerScrollLastY + fingerScrollLastY = event.y + onScroll?.invoke(dy) + } return true } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (!isGutterDragging) return false - isGutterDragging = false - // Detect tap on the logo area (top of gutter, no significant drag) - if (!gutterDragMoved && gutterDragStartY < 130f) { + MotionEvent.ACTION_UP -> { + if (iconVisible && isInIconArea(event.x, event.y)) { onLogoTap?.invoke() + return true + } + if (tutorialMode && event.y < closeButtonHeight) { + onCloseTutorialTap?.invoke() + return true + } + if (textTapTracking) { + textTapTracking = false + resolveTextTap(event.x, event.y) + return true } + fingerScrollActive = false + return true + } + MotionEvent.ACTION_CANCEL -> { + textTapTracking = false + fingerScrollActive = false return true } } return false } + /** Check if touch coordinates are within the floating icon tap target. */ + private fun isInIconArea(x: Float, y: Float): Boolean { + return x >= width - iconSize && y <= iconSize + } + /** Resolve a tap at screen coordinates (x, y) to a written lineIndex. */ private fun resolveTextTap(x: Float, y: Float) { if (renderItems.isEmpty()) return val callback = onTextTap ?: return - val baseY = height - totalTextHeight - BOTTOM_PADDING + val baseY = (height - totalTextHeight).toFloat() val startY = baseY + textScrollOffset + textContentScroll val localX = x - HORIZONTAL_PADDING @@ -481,27 +634,24 @@ class RecognizedTextView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - val gutterLeft = width - HandwritingCanvasView.GUTTER_WIDTH - val gutterCenterX = gutterLeft + HandwritingCanvasView.GUTTER_WIDTH / 2f - // Draw text content or status/hint message if (statusMessage.isNotEmpty() && renderItems.isEmpty()) { // Draw status message centered in the content area - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val contentCenterY = height / 2f canvas.drawText(statusMessage, contentCenterX, contentCenterY, statusPaint) if (statusSubtext.isNotEmpty()) { canvas.drawText(statusSubtext, contentCenterX, contentCenterY + 50f, statusSubtextPaint) } } else if (showScrollHint && renderItems.isEmpty() && !tutorialMode) { - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val contentCenterY = height / 2f canvas.drawText("Scroll to turn writing into text", contentCenterX, contentCenterY, scrollHintPaint) } else if (renderItems.isNotEmpty()) { val baseY = if (tutorialMode) { closeButtonHeight + 5f // top-align below close button } else { - height - totalTextHeight - BOTTOM_PADDING + (height - totalTextHeight).toFloat() } val startY = baseY + textScrollOffset + textContentScroll @@ -519,17 +669,16 @@ class RecognizedTextView @JvmOverloads constructor( canvas.restore() } - // Draw gutter background - canvas.drawRect(gutterLeft, 0f, width.toFloat(), height.toFloat(), gutterPaint) - canvas.drawLine(gutterLeft, 0f, gutterLeft, height.toFloat(), gutterLinePaint) - - // Draw "I" logo in the top of the gutter - val logoY = 100f - canvas.drawText("I", gutterCenterX, logoY, logoPaint) + // Draw floating "I" icon in top-right corner + if (iconVisible) { + val iconCenterX = width - iconSize / 2f + val logoY = iconSize * 0.7f + canvas.drawText("I", iconCenterX, logoY, logoPaint) + } if (tutorialMode) { // "Close Tutorial" button centered at top of text area - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val btnTextY = 52f canvas.drawText("Close Tutorial", contentCenterX, btnTextY, closeButtonPaint) val btnTextWidth = closeButtonPaint.measureText("Close Tutorial") @@ -544,29 +693,30 @@ class RecognizedTextView @JvmOverloads constructor( closeButtonBorderPaint ) - // Arrow pointing at "I" logo saying "Menu" - val menuArrowY = logoY - 20f - val menuArrowRight = gutterLeft - 10f - val menuArrowLeft = gutterLeft - 180f + // Arrow pointing at floating "I" icon saying "Menu" + val iconCenterX = width - iconSize / 2f + val menuArrowY = iconSize * 0.5f + val menuArrowRight = iconCenterX - iconSize / 2f - 10f + val menuArrowLeft = menuArrowRight - 170f canvas.drawLine(menuArrowLeft, menuArrowY, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawLine(menuArrowRight - 20f, menuArrowY - 12f, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawLine(menuArrowRight - 20f, menuArrowY + 12f, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawText("Menu", menuArrowLeft - 110f, menuArrowY + 12f, tutorialTextPaint) - // "Drag gutter to resize" with arrow pointing right toward gutter + // "Drag divider to resize" annotation at bottom val resizeY = height - 60f - val resizeLeft = gutterLeft - 370f - val resizeRight = gutterLeft - 20f - canvas.drawLine(resizeLeft, resizeY, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawLine(resizeRight - 20f, resizeY - 12f, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawLine(resizeRight - 20f, resizeY + 12f, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawText("Drag this gutter to resize", resizeLeft - 10f, resizeY - 21f, tutorialTextPaint) + canvas.drawText("Drag divider to resize", width / 2f, resizeY, tutorialTextPaint) } } private fun drawDiagramItem(canvas: Canvas, item: DiagramRenderItem) { if (item.strokes.isEmpty()) return canvas.save() + // Always clip diagram to its allocated height so strokes don't overflow into adjacent items + canvas.clipRect( + -HORIZONTAL_PADDING, 0f, + width.toFloat(), item.heightPx + ) // Undo the HORIZONTAL_PADDING translation so diagram uses full page width canvas.translate(-HORIZONTAL_PADDING, 0f) canvas.scale(item.scale, item.scale) diff --git a/app/src/main/java/com/writer/view/ScreenMetrics.kt b/app/src/main/java/com/writer/view/ScreenMetrics.kt index 5e17cde..3b81917 100644 --- a/app/src/main/java/com/writer/view/ScreenMetrics.kt +++ b/app/src/main/java/com/writer/view/ScreenMetrics.kt @@ -1,23 +1,26 @@ package com.writer.view +import android.util.TypedValue import kotlin.math.roundToInt /** * Converts dp/sp design constants to device pixels using Android's standard - * density system ([DisplayMetrics.density] and [DisplayMetrics.scaledDensity]). + * density system ([DisplayMetrics.density] and [TypedValue.applyDimension]). * * This is the Android platform best practice: * - **dp** (density-independent pixels) for all spatial measurements. * 1 dp = 1 px at 160 ppi; scaled by [DisplayMetrics.density]. * - **sp** (scale-independent pixels) for text sizes. * Same as dp but additionally respects the user's system font-size preference - * via [DisplayMetrics.scaledDensity]. + * via [TypedValue.applyDimension] with [TypedValue.COMPLEX_UNIT_SP]. * * The compact/standard breakpoint uses [Configuration.smallestScreenWidthDp] — * the same mechanism Android resource qualifiers (e.g. `values-sw600dp/`) use — * rather than computing a physical diagonal. * - * Call [init] once in Application.onCreate() before any view is inflated. + * Call [init] once in `Application.onCreate()` before any view is inflated, + * and re-call it in `onConfigurationChanged` if the user changes font scale + * or display density at runtime. * Tests use the plain-value overload to avoid an Android framework dependency. */ object ScreenMetrics { @@ -28,16 +31,10 @@ object ScreenMetrics { // ── Standard dp constants (Go 7, Note 5C, Tab X C) ─────────────────────── private const val LINE_SPACING_DP = 63f // ≈ 10.0 mm - private const val GUTTER_TARGET_DP = 69f // ≈ 11.0 mm - private const val GUTTER_MIN_DP = 57f // ≈ 9.0 mm (hard floor) - private const val GUTTER_MAX_FRACTION = 0.12f // ≤ 12 % of screen width private const val CANVAS_FRACTION = 0.70f // 70 % of screen height for canvas // ── Compact dp constants (Palma 2 Pro) ─────────────────────────────────── private const val LINE_SPACING_COMPACT_DP = 41f // ≈ 6.5 mm - private const val GUTTER_TARGET_COMPACT_DP = 47f // ≈ 7.5 mm - private const val GUTTER_MIN_COMPACT_DP = 38f // ≈ 6.0 mm (stylus floor) - private const val GUTTER_MAX_FRACTION_COMPACT = 0.09f // ≤ 9 % of screen width private const val CANVAS_FRACTION_COMPACT = 0.82f // 82 % of screen height for canvas // ── Shared dp constants ─────────────────────────────────────────────────── @@ -55,13 +52,16 @@ object ScreenMetrics { // ── Computed pixel values (set by init) ─────────────────────────────────── var density: Float = 1f; private set - var scaledDensity: Float = 1f; private set /** True when the device's smallestScreenWidthDp is below [COMPACT_SW_DP]. */ var isCompact: Boolean = false; private set + // DisplayMetrics reference for proper SP conversion (null in tests) + private var displayMetrics: android.util.DisplayMetrics? = null + // Font scale for test init (1.0 = no scaling) + private var fontScale: Float = 1f + var lineSpacing: Float = 100f; private set var topMargin: Float = 30f; private set - var gutterWidth: Float = 110f; private set var strokeWidth: Float = 4f; private set var textBody: Float = 52f; private set var textLogo: Float = 96f; private set @@ -83,55 +83,62 @@ object ScreenMetrics { displayMetrics: android.util.DisplayMetrics, configuration: android.content.res.Configuration ) { - init( - density = displayMetrics.density, - scaledDensity = displayMetrics.scaledDensity, - smallestWidthDp = configuration.smallestScreenWidthDp, - widthPixels = displayMetrics.widthPixels, - heightPixels = displayMetrics.heightPixels + this.displayMetrics = displayMetrics + initLayout( + density = displayMetrics.density, + smallestWidthDp = configuration.smallestScreenWidthDp, + widthPixels = displayMetrics.widthPixels, + heightPixels = displayMetrics.heightPixels ) + computeTextSizes() } /** * Plain-value overload for unit tests — no Android framework dependency. * * @param density [DisplayMetrics.density] (= densityDpi / 160) - * @param scaledDensity [DisplayMetrics.scaledDensity] (density × fontScale) + * @param fontScale User font scale preference (1.0 = default, >1.0 = larger text) * @param smallestWidthDp [Configuration.smallestScreenWidthDp] - * @param widthPixels screen width in pixels (used for gutter cap) + * @param widthPixels screen width in pixels * @param heightPixels screen height in pixels */ fun init( density: Float, - scaledDensity: Float, + fontScale: Float = 1f, + smallestWidthDp: Int, + widthPixels: Int, + heightPixels: Int + ) { + this.displayMetrics = null + this.fontScale = fontScale.coerceAtLeast(0.5f) + initLayout(density, smallestWidthDp, widthPixels, heightPixels) + computeTextSizes() + } + + private fun initLayout( + density: Float, smallestWidthDp: Int, widthPixels: Int, heightPixels: Int ) { - this.density = density.coerceAtLeast(0.5f) - this.scaledDensity = scaledDensity.coerceAtLeast(0.5f) - isCompact = smallestWidthDp < COMPACT_SW_DP + this.density = density.coerceAtLeast(0.5f) + isCompact = smallestWidthDp < COMPACT_SW_DP val lineSpacingDp = if (isCompact) LINE_SPACING_COMPACT_DP else LINE_SPACING_DP - val gutterTargetDp = if (isCompact) GUTTER_TARGET_COMPACT_DP else GUTTER_TARGET_DP - val gutterMinDp = if (isCompact) GUTTER_MIN_COMPACT_DP else GUTTER_MIN_DP - val gutterMaxFrac = if (isCompact) GUTTER_MAX_FRACTION_COMPACT else GUTTER_MAX_FRACTION canvasFraction = if (isCompact) CANVAS_FRACTION_COMPACT else CANVAS_FRACTION lineSpacing = (lineSpacingDp * this.density).roundToInt().toFloat() topMargin = (TOP_MARGIN_DP * this.density).roundToInt().toFloat() strokeWidth = STROKE_WIDTH_DP * this.density - gutterWidth = (gutterTargetDp * this.density) - .coerceAtMost(widthPixels * gutterMaxFrac) - .coerceAtLeast(gutterMinDp * this.density) - .roundToInt().toFloat() - - textBody = TEXT_BODY_SP * this.scaledDensity - textLogo = TEXT_LOGO_SP * this.scaledDensity - textStatus = TEXT_STATUS_SP * this.scaledDensity - textSubtext = TEXT_SUBTEXT_SP * this.scaledDensity - textCloseBtn = TEXT_CLOSE_BTN_SP * this.scaledDensity - textTutorial = TEXT_TUTORIAL_SP * this.scaledDensity + } + + private fun computeTextSizes() { + textBody = spToPx(TEXT_BODY_SP) + textLogo = spToPx(TEXT_LOGO_SP) + textStatus = spToPx(TEXT_STATUS_SP) + textSubtext = spToPx(TEXT_SUBTEXT_SP) + textCloseBtn = spToPx(TEXT_CLOSE_BTN_SP) + textTutorial = spToPx(TEXT_TUTORIAL_SP) } // ── Conversion helpers ──────────────────────────────────────────────────── @@ -139,8 +146,24 @@ object ScreenMetrics { /** Convert dp to pixels at the current display density. */ fun dp(value: Float): Float = value * density - /** Convert sp to pixels, respecting the user's font-size preference. */ - fun sp(value: Float): Float = value * scaledDensity + /** + * Convert sp to pixels, respecting the user's font-size preference. + * Uses [TypedValue.applyDimension] for proper adaptive font scaling on API 34+. + */ + fun sp(value: Float): Float = spToPx(value) + + /** + * Internal SP to pixel conversion using [TypedValue.applyDimension] when available. + * Falls back to manual calculation for tests without Android framework. + */ + private fun spToPx(value: Float): Float { + val dm = displayMetrics + return if (dm != null) { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, dm) + } else { + value * density * fontScale + } + } // ── Layout helpers ──────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/writer/view/SplitLayout.kt b/app/src/main/java/com/writer/view/SplitLayout.kt new file mode 100644 index 0000000..8cd4ca2 --- /dev/null +++ b/app/src/main/java/com/writer/view/SplitLayout.kt @@ -0,0 +1,88 @@ +package com.writer.view + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout + +/** + * A vertical LinearLayout that intercepts touch events near the divider + * to enable split-resize dragging. The divider stays visually thin (1dp) + * while the touch target is expanded to [TOUCH_TARGET_DP] on each side. + * + * Set [dividerView] after inflation and [onSplitDrag] to receive drag deltas. + */ +class SplitLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + companion object { + /** Touch target expansion on each side of the divider, in dp. */ + private const val TOUCH_TARGET_DP = 24f + } + + /** The divider View whose position defines the drag zone. Must be set after inflation. */ + var dividerView: View? = null + + /** Called during a split drag with the raw Y delta in pixels. */ + var onSplitDrag: ((delta: Float) -> Unit)? = null + + /** Called when a split drag starts. */ + var onSplitDragStart: (() -> Unit)? = null + + /** Called when a split drag ends. */ + var onSplitDragEnd: (() -> Unit)? = null + + private var dragging = false + private var dragLastY = 0f + + private val touchTargetPx get() = ScreenMetrics.dp(TOUCH_TARGET_DP) + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + // Only intercept finger touches — stylus must pass through for writing + if (ev.getToolType(0) != MotionEvent.TOOL_TYPE_FINGER) { + return super.onInterceptTouchEvent(ev) + } + + val divider = dividerView ?: return super.onInterceptTouchEvent(ev) + + when (ev.action) { + MotionEvent.ACTION_DOWN -> { + if (isNearDivider(ev.y, divider)) { + dragging = true + dragLastY = ev.rawY + onSplitDragStart?.invoke() + return true + } + } + } + return super.onInterceptTouchEvent(ev) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!dragging) return super.onTouchEvent(event) + + when (event.action) { + MotionEvent.ACTION_MOVE -> { + val delta = event.rawY - dragLastY + dragLastY = event.rawY + onSplitDrag?.invoke(delta) + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + dragging = false + onSplitDragEnd?.invoke() + return true + } + } + return true + } + + private fun isNearDivider(y: Float, divider: View): Boolean { + val dividerCenter = divider.top + divider.height / 2f + return kotlin.math.abs(y - dividerCenter) <= touchTargetPx + } +} diff --git a/app/src/main/java/com/writer/view/TouchFilter.kt b/app/src/main/java/com/writer/view/TouchFilter.kt new file mode 100644 index 0000000..19edeb5 --- /dev/null +++ b/app/src/main/java/com/writer/view/TouchFilter.kt @@ -0,0 +1,126 @@ +package com.writer.view + +/** + * Layered palm-rejection filter for finger touches. + * + * Pure logic — no Android view dependency. Takes primitive parameters, returns + * accept/reject decisions. Unit-testable on JVM. + * + * Filter layers (cheapest first): + * 1. Concurrent stylus suppression — pen is down + * 2. Pen cooldown — pen lifted within [penCooldownMs] + * 3. Touch size threshold — contact area too large (palm) + * 4. Multi-touch rejection — pointerCount > 1 + * 5. Stationary contact timeout — finger hasn't moved past slop (canvas only) + */ +class TouchFilter( + private val palmSizeThresholdDp: Float = 40f, + private val penCooldownMs: Long = 150L, + private val stationarySlopDp: Float = 8f, + private val stationaryTimeoutMs: Long = 200L, +) { + + enum class Decision { ACCEPT, REJECT } + + // --- Pen state, set by the view --- + + @Volatile var penActive: Boolean = false + + /** Timestamp (uptimeMillis) when pen last lifted. */ + @Volatile var penUpTimestamp: Long = 0L + + // --- Per-touch tracking for stationary check --- + + private var fingerDownX: Float = 0f + private var fingerDownY: Float = 0f + private var fingerDownTime: Long = 0L + private var fingerMovedPastSlop: Boolean = false + + /** + * Evaluate whether a finger ACTION_DOWN should be accepted. + * + * @param pointerCount number of active pointers + * @param touchMinorDp minor axis of touch area in dp + * @param eventTime uptimeMillis of the event + * @param x screen x of the touch + * @param y screen y of the touch + */ + fun evaluateDown( + pointerCount: Int, + touchMinorDp: Float, + eventTime: Long, + x: Float, + y: Float, + ): Decision { + // Layer 1: pen is currently down + if (penActive) return Decision.REJECT + + // Layer 2: pen lifted recently + if (penUpTimestamp > 0L && (eventTime - penUpTimestamp) < penCooldownMs) { + return Decision.REJECT + } + + // Layer 3: contact area too large + if (touchMinorDp > palmSizeThresholdDp) return Decision.REJECT + + // Layer 4: multi-touch + if (pointerCount > 1) return Decision.REJECT + + // Passed all down-time checks — start tracking for stationary timeout + fingerDownX = x + fingerDownY = y + fingerDownTime = eventTime + fingerMovedPastSlop = false + + return Decision.ACCEPT + } + + /** + * Evaluate whether a finger ACTION_MOVE should be accepted. + * Must be called on every move after a successful [evaluateDown]. + * + * @param pointerCount number of active pointers + * @param touchMinorDp minor axis of touch area in dp + * @param eventTime uptimeMillis of the event + * @param x screen x + * @param y screen y + * @param checkStationary true for canvas (reject resting palm), false for text view + */ + fun evaluateMove( + pointerCount: Int, + touchMinorDp: Float, + eventTime: Long, + x: Float, + y: Float, + checkStationary: Boolean, + ): Decision { + // Layer 1: pen went down mid-gesture + if (penActive) return Decision.REJECT + + // Layer 3: contact area grew (palm settling) + if (touchMinorDp > palmSizeThresholdDp) return Decision.REJECT + + // Layer 4: second finger appeared + if (pointerCount > 1) return Decision.REJECT + + // Layer 5: stationary contact timeout (canvas only) + if (checkStationary && !fingerMovedPastSlop) { + val dx = x - fingerDownX + val dy = y - fingerDownY + val slopPx = stationarySlopDp * ScreenMetrics.density + if (dx * dx + dy * dy > slopPx * slopPx) { + fingerMovedPastSlop = true + } else if ((eventTime - fingerDownTime) > stationaryTimeoutMs) { + return Decision.REJECT + } + } + + return Decision.ACCEPT + } + + /** + * Check whether enough movement has happened for a scroll gesture. + * Call after [evaluateMove] returns ACCEPT. + */ + fun hasMovedPastSlop(): Boolean = fingerMovedPastSlop +} diff --git a/app/src/main/res/layout/activity_writing.xml b/app/src/main/res/layout/activity_writing.xml index 83dd92c..53fd024 100644 --- a/app/src/main/res/layout/activity_writing.xml +++ b/app/src/main/res/layout/activity_writing.xml @@ -1,6 +1,7 @@ - - + @@ -27,4 +29,4 @@ android:layout_height="0dp" android:layout_weight="3" /> - + diff --git a/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt b/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt new file mode 100644 index 0000000..9d8c8b1 --- /dev/null +++ b/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt @@ -0,0 +1,79 @@ +package com.onyx.android.sdk.hwr.service + +import android.app.Application +import android.os.Parcel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class HWRInputArgsTest { + + @Test + fun parcelRoundTrip_preservesAllFields() { + val original = HWRInputArgs().apply { + lang = "fr_FR" + contentType = "Math" + recognizerType = "MS_ON_SCREEN" + viewWidth = 1920f + viewHeight = 1080f + offsetX = 10f + offsetY = 20f + isGestureEnable = true + isTextEnable = false + isShapeEnable = true + isIncremental = true + content = "some content" + } + + val parcel = Parcel.obtain() + try { + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = HWRInputArgs(parcel) + + assertEquals(original.lang, restored.lang) + assertEquals(original.contentType, restored.contentType) + assertEquals(original.recognizerType, restored.recognizerType) + assertEquals(original.viewWidth, restored.viewWidth, 0f) + assertEquals(original.viewHeight, restored.viewHeight, 0f) + assertEquals(original.offsetX, restored.offsetX, 0f) + assertEquals(original.offsetY, restored.offsetY, 0f) + assertEquals(original.isGestureEnable, restored.isGestureEnable) + assertEquals(original.isTextEnable, restored.isTextEnable) + assertEquals(original.isShapeEnable, restored.isShapeEnable) + assertEquals(original.isIncremental, restored.isIncremental) + assertEquals(original.content, restored.content) + } finally { + parcel.recycle() + } + } + + @Test + fun parcelRoundTrip_defaultValues() { + val original = HWRInputArgs() + + val parcel = Parcel.obtain() + try { + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = HWRInputArgs(parcel) + + assertEquals("en_US", restored.lang) + assertEquals("Text", restored.contentType) + assertEquals("Text", restored.recognizerType) + assertEquals(0f, restored.viewWidth, 0f) + assertEquals(0f, restored.viewHeight, 0f) + assertNull(restored.pfd) + assertNull(restored.content) + } finally { + parcel.recycle() + } + } +} diff --git a/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt b/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt new file mode 100644 index 0000000..e1b8b96 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt @@ -0,0 +1,355 @@ +package com.writer.recognition + +import android.graphics.RectF +import com.writer.model.InkLine +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Tests for [HwrProtobuf] — protobuf encoding for the Boox MyScript HWR service + * and JSON result parsing. + */ +class HwrProtobufTest { + + // ── Protobuf encoding ──────────────────────────────────────────────────── + + @Test fun protobuf_containsLanguageField() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + // Field 1 (lang) should be "en_US" + val lang = fields.firstOrNull { it.fieldNumber == 1 } + assertEquals("en_US", lang?.stringValue) + } + + @Test fun protobuf_containsContentType() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val contentType = fields.firstOrNull { it.fieldNumber == 2 } + assertEquals("Text", contentType?.stringValue) + } + + @Test fun protobuf_containsRecognizerType() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val recognizerType = fields.firstOrNull { it.fieldNumber == 4 } + assertEquals("MS_ON_SCREEN", recognizerType?.stringValue) + } + + @Test fun protobuf_containsViewDimensions() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val width = fields.firstOrNull { it.fieldNumber == 5 } + val height = fields.firstOrNull { it.fieldNumber == 6 } + assertEquals(1000f, width?.floatValue) + assertEquals(500f, height?.floatValue) + } + + @Test fun protobuf_singleStroke_producesDownAndUpEvents() { + // Single stroke with 2 points → DOWN then UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(10f, 20f, 0.5f, 1000L), + StrokePoint(30f, 40f, 0.6f, 1010L) + ) + ) + val line = InkLine(listOf(stroke), RectF(10f, 20f, 30f, 40f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + + // Field 15 = pointer events (length-delimited) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("2 points → 2 pointer events", 2, pointerEvents.size) + + // Verify event types: first=DOWN(0), last=UP(2) + val firstEvent = parseProtobuf(pointerEvents[0].bytesValue!!) + val lastEvent = parseProtobuf(pointerEvents[1].bytesValue!!) + assertEquals("First point should be DOWN", 0L, firstEvent.first { it.fieldNumber == 6 }.varintValue) + assertEquals("Last point should be UP", 2L, lastEvent.first { it.fieldNumber == 6 }.varintValue) + } + + @Test fun protobuf_multiPointStroke_producesDownMoveUpEvents() { + // Stroke with 4 points → DOWN, MOVE, MOVE, UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L), + StrokePoint(20f, 20f, 0.5f, 120L), + StrokePoint(30f, 30f, 0.5f, 130L) + ) + ) + val line = InkLine(listOf(stroke), RectF(0f, 0f, 30f, 30f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("4 points → 4 pointer events", 4, pointerEvents.size) + } + + @Test fun protobuf_emptyStroke_skipped() { + val stroke = InkStroke(strokeId = "s1", points = emptyList()) + val line = InkLine(listOf(stroke), RectF()) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("Empty stroke should produce no pointer events", 0, pointerEvents.size) + } + + @Test fun protobuf_multipleStrokes_allPointsEncoded() { + val stroke1 = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L) + ) + ) + val stroke2 = InkStroke( + strokeId = "s2", + points = listOf( + StrokePoint(20f, 0f, 0.5f, 200L), + StrokePoint(30f, 10f, 0.5f, 210L), + StrokePoint(40f, 20f, 0.5f, 220L) + ) + ) + val line = InkLine(listOf(stroke1, stroke2), RectF(0f, 0f, 40f, 20f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("2+3 points → 5 pointer events", 5, pointerEvents.size) + } + + @Test fun protobuf_singlePointStroke_producesDownAndUpEvents() { + // Single-point stroke → must emit both DOWN and UP (regression: previously only DOWN) + val line = singlePointLine(50f, 75f) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("1-point stroke → 2 pointer events (DOWN+UP)", 2, pointerEvents.size) + + val downEvent = parseProtobuf(pointerEvents[0].bytesValue!!) + val upEvent = parseProtobuf(pointerEvents[1].bytesValue!!) + assertEquals("First event should be DOWN", 0L, downEvent.first { it.fieldNumber == 6 }.varintValue) + assertEquals("Second event should be UP", 2L, upEvent.first { it.fieldNumber == 6 }.varintValue) + + // Both events should have the same coordinates + assertEquals(50f, downEvent.first { it.fieldNumber == 1 }.floatValue) + assertEquals(75f, downEvent.first { it.fieldNumber == 2 }.floatValue) + assertEquals(50f, upEvent.first { it.fieldNumber == 1 }.floatValue) + assertEquals(75f, upEvent.first { it.fieldNumber == 2 }.floatValue) + } + + @Test fun protobuf_customLanguage_isEncoded() { + val line = singlePointLine(0f, 0f) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f, lang = "zh_CN") + val fields = parseProtobuf(bytes) + val lang = fields.firstOrNull { it.fieldNumber == 1 } + assertEquals("zh_CN", lang?.stringValue) + } + + @Test fun protobuf_multiPointStroke_eventTypeSequence() { + // 4 points → DOWN, MOVE, MOVE, UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L), + StrokePoint(20f, 20f, 0.5f, 120L), + StrokePoint(30f, 30f, 0.5f, 130L) + ) + ) + val line = InkLine(listOf(stroke), RectF(0f, 0f, 30f, 30f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + + val eventTypes = pointerEvents.map { event -> + parseProtobuf(event.bytesValue!!).first { it.fieldNumber == 6 }.varintValue + } + assertEquals("DOWN, MOVE, MOVE, UP", listOf(0L, 1L, 1L, 2L), eventTypes) + } + + @Test fun protobuf_pointerEvent_coordinatesAndPressureRoundTrip() { + val bytes = HwrProtobuf.encodePointerProto( + x = 123.456f, y = 789.012f, t = 999L, f = 0.42f, + pointerId = 0, eventType = 1, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(123.456f, fields.first { it.fieldNumber == 1 }.floatValue) + assertEquals(789.012f, fields.first { it.fieldNumber == 2 }.floatValue) + assertEquals(0.42f, fields.first { it.fieldNumber == 4 }.floatValue) + } + + @Test fun protobuf_outputIsNonEmpty() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + assertTrue("Protobuf output should be non-empty", bytes.isNotEmpty()) + } + + // ── Pointer event encoding ─────────────────────────────────────────────── + + @Test fun pointerProto_containsCoordinates() { + val bytes = HwrProtobuf.encodePointerProto( + x = 42.5f, y = 99.0f, t = 12345L, f = 0.7f, + pointerId = 0, eventType = 1, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(42.5f, fields.first { it.fieldNumber == 1 }.floatValue) + assertEquals(99.0f, fields.first { it.fieldNumber == 2 }.floatValue) + } + + @Test fun pointerProto_containsPressure() { + val bytes = HwrProtobuf.encodePointerProto( + x = 0f, y = 0f, t = 0L, f = 0.85f, + pointerId = 0, eventType = 0, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(0.85f, fields.first { it.fieldNumber == 4 }.floatValue) + } + + @Test fun pointerProto_containsEventType() { + for (eventType in listOf(0, 1, 2)) { + val bytes = HwrProtobuf.encodePointerProto( + x = 0f, y = 0f, t = 0L, f = 0.5f, + pointerId = 0, eventType = eventType, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals( + "Event type $eventType should be encoded", + eventType.toLong(), fields.first { it.fieldNumber == 6 }.varintValue + ) + } + } + + // ── JSON result parsing ────────────────────────────────────────────────── + + @Test fun parseResult_successWithLabel() { + val json = """{"result":{"label":"hello world"}}""" + assertEquals("hello world", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_topLevelLabel() { + val json = """{"label":"fallback text"}""" + assertEquals("fallback text", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_emptyResult() { + val json = """{"result":{"label":""}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_exception_returnsEmpty() { + val json = """{"exception":{"cause":{"message":"recognition failed"}}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_invalidJson_returnsEmpty() { + assertEquals("", HwrProtobuf.parseHwrResult("not json")) + } + + @Test fun parseResult_emptyString_returnsEmpty() { + assertEquals("", HwrProtobuf.parseHwrResult("")) + } + + @Test fun parseResult_noLabelField_returnsEmpty() { + val json = """{"result":{"other":"data"}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + // ── Protobuf primitives ────────────────────────────────────────────────── + + @Test fun writeVarint_smallValue() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeVarint(out, 1L) + assertEquals(1, out.size()) + assertEquals(1, out.toByteArray()[0].toInt()) + } + + @Test fun writeVarint_multiByteValue() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeVarint(out, 300L) + // 300 = 0b100101100 → varint: 0xAC 0x02 + assertEquals(2, out.size()) + } + + @Test fun writeFixed32_encodesFloatCorrectly() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeFixed32(out, 1.0f) + val expected = java.lang.Float.floatToIntBits(1.0f) + val actual = ByteBuffer.wrap(out.toByteArray()).order(ByteOrder.LITTLE_ENDIAN).int + assertEquals(expected, actual) + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private fun singlePointLine(x: Float, y: Float): InkLine { + val stroke = InkStroke( + strokeId = "test", + points = listOf(StrokePoint(x, y, 0.5f, 1000L)) + ) + return InkLine(listOf(stroke), RectF(x, y, x, y)) + } + + /** Minimal protobuf field parser for test assertions. */ + private data class ProtoField( + val fieldNumber: Int, + val wireType: Int, + val varintValue: Long = 0, + val floatValue: Float? = null, + val stringValue: String? = null, + val bytesValue: ByteArray? = null + ) + + private fun parseProtobuf(data: ByteArray): List { + val fields = mutableListOf() + val stream = ByteArrayInputStream(data) + + while (stream.available() > 0) { + val tag = readVarint(stream) ?: break + val fieldNumber = (tag shr 3).toInt() + val wireType = (tag and 0x7).toInt() + + when (wireType) { + 0 -> { // varint + val value = readVarint(stream) ?: break + fields.add(ProtoField(fieldNumber, wireType, varintValue = value)) + } + 2 -> { // length-delimited + val len = readVarint(stream)?.toInt() ?: break + val bytes = ByteArray(len) + stream.read(bytes) + val str = try { String(bytes, Charsets.UTF_8) } catch (_: Exception) { null } + fields.add(ProtoField(fieldNumber, wireType, stringValue = str, bytesValue = bytes)) + } + 5 -> { // fixed32 + val bytes = ByteArray(4) + stream.read(bytes) + val float = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).float + fields.add(ProtoField(fieldNumber, wireType, floatValue = float)) + } + } + } + return fields + } + + private fun readVarint(stream: ByteArrayInputStream): Long? { + var result = 0L + var shift = 0 + while (true) { + val b = stream.read() + if (b == -1) return null + result = result or ((b.toLong() and 0x7F) shl shift) + if (b and 0x80 == 0) return result + shift += 7 + } + } +} diff --git a/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt b/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt new file mode 100644 index 0000000..9dcdc93 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt @@ -0,0 +1,79 @@ +package com.writer.recognition + +import android.app.Application +import android.os.ParcelFileDescriptor +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * Tests for the pipe-based IPC used by [OnyxHwrTextRecognizer] + * to pass protobuf data to the Boox HWR service via ParcelFileDescriptor. + * + * Runs under Robolectric with a plain Application to avoid WriterApplication's + * HiddenApiBypass dependency on sun.misc.Unsafe. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class OnyxHwrTextRecognizerTest { + + // ── Pipe-based IPC ──────────────────────────────────────────────────────── + + @Test + fun pipe_roundTrips_data() { + val data = ByteArray(17_000) { (it % 256).toByte() } + val pipe = ParcelFileDescriptor.createPipe() + val readPfd = pipe[0] + val writePfd = pipe[1] + + FileOutputStream(writePfd.fileDescriptor).use { it.write(data) } + writePfd.close() + + val readBack = FileInputStream(readPfd.fileDescriptor).use { it.readBytes() } + readPfd.close() + + assertEquals(data.size, readBack.size) + for (i in data.indices) { + assertEquals("Byte $i", data[i], readBack[i]) + } + } + + // ── End-to-end: real protobuf through pipe ────────────────────────────── + + @Test + fun realProtobuf_throughPipe_roundTrips() { + val stroke = com.writer.model.InkStroke( + strokeId = "s1", + points = listOf( + com.writer.model.StrokePoint(100f, 200f, 0.5f, 1000L), + com.writer.model.StrokePoint(110f, 210f, 0.6f, 1010L), + com.writer.model.StrokePoint(120f, 220f, 0.7f, 1020L) + ) + ) + val line = com.writer.model.InkLine( + listOf(stroke), + android.graphics.RectF(100f, 200f, 120f, 220f) + ) + val protoBytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f, "en_US") + + val pipe = ParcelFileDescriptor.createPipe() + val readPfd = pipe[0] + val writePfd = pipe[1] + + FileOutputStream(writePfd.fileDescriptor).use { it.write(protoBytes) } + writePfd.close() + + val readBack = FileInputStream(readPfd.fileDescriptor).use { it.readBytes() } + readPfd.close() + + assertEquals("Protobuf bytes should survive pipe round-trip", + protoBytes.size, readBack.size) + for (i in protoBytes.indices) { + assertEquals("Byte $i", protoBytes[i], readBack[i]) + } + } +} diff --git a/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt b/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt new file mode 100644 index 0000000..03f798f --- /dev/null +++ b/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt @@ -0,0 +1,90 @@ +package com.writer.recognition + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Test + +class StrokeDownsamplerTest { + + private fun pt(x: Float, y: Float, pressure: Float = 100f, timestamp: Long = 0L) = + StrokePoint(x, y, pressure, timestamp) + + @Test + fun collapseIdenticalPositions_collapsesDwell() { + val points = listOf( + pt(10f, 20f, 100f, 1L), + pt(10f, 20f, 200f, 2L), + pt(10f, 20f, 300f, 3L), + pt(10f, 20f, 400f, 4L), + pt(15f, 25f, 500f, 5L), + ) + val result = StrokeDownsampler.collapseIdenticalPositions(points) + assertEquals(3, result.size) + // First point of dwell run + assertEquals(1L, result[0].timestamp) + // Last point of dwell run + assertEquals(4L, result[1].timestamp) + // Next distinct point + assertEquals(5L, result[2].timestamp) + } + + @Test + fun collapseIdenticalPositions_singlePoint() { + val points = listOf(pt(10f, 20f)) + assertEquals(1, StrokeDownsampler.collapseIdenticalPositions(points).size) + } + + @Test + fun collapseIdenticalPositions_noDuplicates() { + val points = listOf(pt(1f, 1f), pt(2f, 2f), pt(3f, 3f)) + assertEquals(3, StrokeDownsampler.collapseIdenticalPositions(points).size) + } + + @Test + fun rdp_epsilonZero_preservesAll() { + val points = listOf(pt(0f, 0f), pt(1f, 1f), pt(2f, 0f)) + val result = StrokeDownsampler.rdp(points, 0f) + assertEquals(3, result.size) + } + + @Test + fun rdp_largeEpsilon_endpointsOnly() { + val points = listOf(pt(0f, 0f), pt(1f, 0.1f), pt(2f, 0f)) + val result = StrokeDownsampler.rdp(points, 100f) + assertEquals(2, result.size) + assertEquals(0f, result[0].x, 0.001f) + assertEquals(2f, result[1].x, 0.001f) + } + + @Test + fun rdp_twoPoints_passthrough() { + val points = listOf(pt(0f, 0f), pt(10f, 10f)) + val result = StrokeDownsampler.rdp(points, 1f) + assertEquals(2, result.size) + } + + @Test + fun rdp_singlePoint_passthrough() { + val points = listOf(pt(5f, 5f)) + assertEquals(1, StrokeDownsampler.rdp(points, 1f).size) + } + + @Test + fun downsample_combinesBothPasses() { + val points = listOf( + // Dwell at start + pt(0f, 0f, 100f, 1L), + pt(0f, 0f, 200f, 2L), + pt(0f, 0f, 300f, 3L), + // Meaningful movement + pt(5f, 5f, 400f, 4L), + pt(10f, 0f, 500f, 5L), + ) + val stroke = InkStroke(strokeId = "test", points = points) + val result = StrokeDownsampler.downsample(stroke, 0.5f) + // Dwell collapsed to 2 points, then RDP keeps significant points + assert(result.points.size < points.size) + assertEquals("test", result.strokeId) + } +} diff --git a/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt b/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt new file mode 100644 index 0000000..72f02c5 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt @@ -0,0 +1,59 @@ +package com.writer.recognition + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ResolveInfo +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class TextRecognizerFactoryTest { + + @Test + fun create_withOnyxService_returnsOnyxRecognizer() { + val app = RuntimeEnvironment.getApplication() + val pm = shadowOf(app.packageManager) + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + pm.addResolveInfoForIntent(intent, ResolveInfo()) + + val recognizer = TextRecognizerFactory.create(app) + assertTrue( + "Expected OnyxHwrTextRecognizer when Onyx service is available", + recognizer is OnyxHwrTextRecognizer + ) + recognizer.close() + } + + @Test + fun create_withoutOnyxService_returnsGoogleMLKit() { + // Robolectric has no Onyx service registered — but GoogleMLKitTextRecognizer + // requires MlKitContext which isn't available in unit tests. + // Instead, verify the selection logic: without the service, isOnyxHwrAvailable returns false. + val app = RuntimeEnvironment.getApplication() + val pm = shadowOf(app.packageManager) + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + // Ensure no resolve info is registered + val resolved = app.packageManager.resolveService(intent, 0) + assertTrue( + "Onyx HWR service should not be resolvable in test environment", + resolved == null + ) + } +} diff --git a/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt b/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt new file mode 100644 index 0000000..db67ea9 --- /dev/null +++ b/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt @@ -0,0 +1,405 @@ +package com.writer.view + +import com.writer.model.DiagramArea +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [PreviewLayoutCalculator] — the pure-logic engine that decides + * what appears in the preview and how it's sized for flush alignment with + * the canvas boundary. + * + * Uses concrete numbers based on the Go 7 device (density 1.875, line + * spacing 118px, top margin 36px) for readability, but the logic is + * density-independent. + */ +class PreviewLayoutCalculatorTest { + + companion object { + // Go 7 at 300 PPI (density = 1.875) + const val LINE_SPACING = 118f // 63dp * 1.875 + const val TOP_MARGIN = 36f // 19dp * 1.875 + const val STROKE_WIDTH = 5f + const val PARAGRAPH_SPACING = 22f // 12dp * 1.875 (approx) + const val CANVAS_WIDTH = 824f + const val TEXT_VIEW_WIDTH = 824f // same width, no gutter + } + + private fun lineTop(idx: Int) = TOP_MARGIN + idx * LINE_SPACING + private fun lineBottom(idx: Int) = lineTop(idx) + LINE_SPACING + private fun lineMid(idx: Int) = lineTop(idx) + LINE_SPACING / 2f + + // ── currentlyHiddenLines ──────────────────────────────────────────── + + @Test fun hidden_noScroll_nothingHidden() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = 0f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertTrue("No lines should be hidden at scroll=0", hidden.isEmpty()) + } + + @Test fun hidden_scrollPastFirstLine_firstLineHidden() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineBottom(0), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0), hidden) + } + + @Test fun hidden_scrollPartiallyPastLine_notHidden() { + // Scroll to just before line 0's bottom — not fully hidden + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineBottom(0) - 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertTrue("Line 0 should NOT be hidden when scroll is 1px short", hidden.isEmpty()) + } + + @Test fun hidden_scrollPastMultipleLines() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2, 3, 4), + scrollOffsetY = lineBottom(2), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0, 1, 2), hidden) + } + + @Test fun hidden_onlyLinesWithStrokes_emptyGapsIgnored() { + // Lines 0 and 3 have strokes, lines 1 and 2 don't + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 3), + scrollOffsetY = lineBottom(2), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + // Line 0 is hidden, line 3 is not (its bottom is below scroll) + assertEquals(setOf(0), hidden) + } + + // ── notYetVisibleLines ────────────────────────────────────────────── + + @Test fun notYetVisible_usesLineMidpoint() { + // Scroll to exactly line 1's midpoint + val notVisible = PreviewLayoutCalculator.notYetVisibleLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineMid(1), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + // Line 0 midpoint is above scroll, line 1 midpoint is exactly at scroll (<=) + assertEquals(setOf(0, 1), notVisible) + } + + @Test fun notYetVisible_justBeforeMidpoint_notIncluded() { + val notVisible = PreviewLayoutCalculator.notYetVisibleLines( + lineIndices = setOf(0, 1), + scrollOffsetY = lineMid(1) - 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0), notVisible) + } + + // ── diagramVisibilities ────────────────────────────────────────────── + + @Test fun diagram_notScrolledOff_notIncluded() { + val areas = listOf(DiagramArea(startLineIndex = 5, heightInLines = 3)) + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = lineTop(5) - 1f, // 1px before area top + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertTrue("Diagram should not be included before any part scrolls off", result.isEmpty()) + } + + @Test fun diagram_1pxScrolledOff_included() { + val areas = listOf(DiagramArea(startLineIndex = 5, heightInLines = 3)) + val areaTop = lineTop(5) + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = areaTop + 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + assertEquals(1f, result[0].visibleHeight, 0.01f) + assertTrue("Should be partial", result[0].isPartial) + } + + @Test fun diagram_fullyScrolledOff_fullHeight() { + val areas = listOf(DiagramArea(startLineIndex = 2, heightInLines = 4)) + val areaTop = lineTop(2) + val fullHeight = 4 * LINE_SPACING + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = areaTop + fullHeight + 100f, // well past + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + assertFalse("Should not be partial when fully visible", result[0].isPartial) + } + + @Test fun diagram_croppedToStrokeBounds() { + val area = DiagramArea(startLineIndex = 3, heightInLines = 5) + val areaTop = lineTop(3) + val fullHeight = 5 * LINE_SPACING + // Strokes only reach 3 lines down (not all 5) + val strokeMaxY = areaTop + 3 * LINE_SPACING - 10f + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, // fully scrolled + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(3 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + val expectedStrokeBottom = strokeMaxY - areaTop + STROKE_WIDTH + assertEquals(expectedStrokeBottom, result[0].visibleHeight, 0.01f) + assertTrue("Cropped diagram should be partial", result[0].isPartial) + } + + @Test fun diagram_strokeBoundsExceedArea_clampedToFullHeight() { + val area = DiagramArea(startLineIndex = 0, heightInLines = 3) + val areaTop = lineTop(0) + val fullHeight = 3 * LINE_SPACING + // Stroke maxY is beyond the area bottom + val strokeMaxY = areaTop + fullHeight + 50f + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(0 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_partialScroll_croppedToScrolledOff() { + val area = DiagramArea(startLineIndex = 2, heightInLines = 5) + val areaTop = lineTop(2) + // Scroll only 1 line into the diagram + val scrolledOff = LINE_SPACING + // Strokes go all the way down + val strokeMaxY = areaTop + 4.5f * LINE_SPACING + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + scrolledOff, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(2 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + // visibleHeight = min(scrolledOff, strokeBottom) = scrolledOff (since strokes go further) + assertEquals(scrolledOff, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_noStrokes_usesFullHeight() { + val area = DiagramArea(startLineIndex = 1, heightInLines = 3) + val areaTop = lineTop(1) + val fullHeight = 3 * LINE_SPACING + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), // no strokes + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_multipleDiagrams_correctOrder() { + val areas = listOf( + DiagramArea(startLineIndex = 1, heightInLines = 2), + DiagramArea(startLineIndex = 5, heightInLines = 3), + DiagramArea(startLineIndex = 10, heightInLines = 2) + ) + // Scroll past the first two, not the third + val scrollOffsetY = lineTop(8) + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = scrollOffsetY, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(2, result.size) + assertEquals(1, result[0].startLineIndex) + assertEquals(5, result[1].startLineIndex) + } + + // ── diagramRenderMetrics ──────────────────────────────────────────── + + @Test fun renderMetrics_sameWidth_scale1() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 200f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, // same width + paragraphSpacing = PARAGRAPH_SPACING + ) + assertEquals(1f, metrics.scale, 0.001f) + assertTrue(metrics.isPartial) + assertEquals(200f, metrics.renderedHeight, 0.01f) // no spacing for partial + assertEquals(400f + PARAGRAPH_SPACING, metrics.fullRenderedHeight, 0.01f) + } + + @Test fun renderMetrics_fullDiagram_includesSpacing() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 400f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + assertFalse(metrics.isPartial) + assertEquals(400f + PARAGRAPH_SPACING, metrics.renderedHeight, 0.01f) + } + + @Test fun renderMetrics_partialDiagram_noSpacing() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 100f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + assertTrue(metrics.isPartial) + assertEquals(100f, metrics.renderedHeight, 0.01f) // no spacing + } + + @Test fun renderMetrics_differentWidths_scalesCorrectly() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 200f, + fullHeight = 400f, + canvasWidth = 800f, + textViewWidth = 400f, // half width + paragraphSpacing = PARAGRAPH_SPACING + ) + assertEquals(0.5f, metrics.scale, 0.001f) + assertEquals(200f * 0.5f, metrics.renderedHeight, 0.01f) + } + + // ── trimLastItemHeight ────────────────────────────────────────────── + + @Test fun trimLast_textItem_usesLayoutHeight() { + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = 150f, // includes spacing + fullHeightPx = 150f, + paragraphSpacing = PARAGRAPH_SPACING, + isText = true, + textLayoutHeight = 128f // raw layout height without spacing + ) + assertEquals(128f, result, 0.01f) + } + + @Test fun trimLast_diagramItem_removesSpacing() { + val fullRendered = 400f + PARAGRAPH_SPACING + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = fullRendered, + fullHeightPx = fullRendered, + paragraphSpacing = PARAGRAPH_SPACING, + isText = false + ) + assertEquals(400f, result, 0.01f) + } + + @Test fun trimLast_partialDiagram_alreadySmall_noChange() { + // Partial diagram: heightPx = 100 (no spacing), fullHeightPx = 422 + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = 100f, + fullHeightPx = 400f + PARAGRAPH_SPACING, + paragraphSpacing = PARAGRAPH_SPACING, + isText = false + ) + // coerceAtMost(422 - 22) = coerceAtMost(400) → 100 (already smaller) + assertEquals(100f, result, 0.01f) + } + + // ── Complementary view invariants ─────────────────────────────────── + + @Test fun complementary_previewAndCanvasShowComplementaryContent() { + // For any scroll position, the hidden lines + visible lines should + // cover all lines with no overlap. + val allLines = setOf(0, 1, 2, 3, 4, 5) + val scrollOffsetY = lineBottom(2) // lines 0-2 hidden + + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + allLines, scrollOffsetY, TOP_MARGIN, LINE_SPACING + ) + val visible = allLines - hidden + + assertEquals("Hidden + visible should cover all lines", allLines, hidden + visible) + assertTrue("Hidden and visible should not overlap", hidden.intersect(visible).isEmpty()) + assertEquals(setOf(0, 1, 2), hidden) + assertEquals(setOf(3, 4, 5), visible) + } + + @Test fun complementary_diagramVisibleHeight_matchesScrolledOffPortion() { + // The preview shows exactly what's scrolled off — no more, no less. + val area = DiagramArea(startLineIndex = 3, heightInLines = 4) + val areaTop = lineTop(3) + val scrolledAmount = 2.5f * LINE_SPACING + val scrollOffsetY = areaTop + scrolledAmount + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = scrollOffsetY, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), // no stroke cropping + strokeWidthPadding = STROKE_WIDTH + ) + + // Preview shows scrolledAmount, canvas shows the rest + assertEquals(scrolledAmount, result[0].visibleHeight, 0.01f) + val canvasRemaining = result[0].fullHeight - result[0].visibleHeight + assertEquals( + "Preview + canvas should equal full height", + result[0].fullHeight, + result[0].visibleHeight + canvasRemaining, + 0.01f + ) + } + + @Test fun complementary_partialDiagram_noSpacingGap() { + // A partial diagram should have zero spacing after it, ensuring the + // preview content is flush against the divider. + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 150f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = TEXT_VIEW_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + // renderedHeight should NOT include spacing for partial diagrams + assertEquals(150f, metrics.renderedHeight, 0.01f) + } +} diff --git a/app/src/test/java/com/writer/view/ScreenMetricsTest.kt b/app/src/test/java/com/writer/view/ScreenMetricsTest.kt index fbabe13..ed86b09 100644 --- a/app/src/test/java/com/writer/view/ScreenMetricsTest.kt +++ b/app/src/test/java/com/writer/view/ScreenMetricsTest.kt @@ -49,13 +49,13 @@ class ScreenMetricsTest { // Re-initialise before each test so state from previous tests doesn't leak. @Before fun resetToDefault() { - ScreenMetrics.init(DENSITY, DENSITY, SW_GO_7, W_GO_7, H_GO_7) + ScreenMetrics.init(DENSITY, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) } // ── helpers ────────────────────────────────────────────────────────────── private fun init(sw: Int, w: Int, h: Int) = - ScreenMetrics.init(DENSITY, DENSITY, sw, w, h) + ScreenMetrics.init(DENSITY, smallestWidthDp = sw, widthPixels = w, heightPixels = h) /** Convert pixels back to mm at 300 PPI. */ private fun toMm(px: Float) = px / (DENSITY * 160f) * 25.4f @@ -91,45 +91,6 @@ class ScreenMetricsTest { assertTrue("lineSpacing should be <= 9 mm on Palma 2 Pro, was $mm mm", mm <= 9f) } - // ── gutter width ───────────────────────────────────────────────────────── - - @Test fun gutterWidth_isAtLeastMinimumTouchTarget_standardDevices() { - val standard = listOf( - Triple(SW_TAB_X_C, W_TAB_X_C, H_TAB_X_C), - Triple(SW_NOTE_5C, W_NOTE_5C, H_NOTE_5C), - Triple(SW_GO_7, W_GO_7, H_GO_7), - ) - for ((sw, w, h) in standard) { - init(sw, w, h) - val mm = toMm(ScreenMetrics.gutterWidth) - assertTrue("gutterWidth < 9 mm on sw=$sw (was $mm mm)", mm >= 9f) - } - } - - @Test fun gutterWidth_isAtLeastStylusTarget_compactDevices() { - // Compact screens use a narrower gutter; 6 mm is still reliable for a stylus. - init(SW_PALMA_2PRO, W_PALMA_2PRO, H_PALMA_2PRO) - val mm = toMm(ScreenMetrics.gutterWidth) - assertTrue("gutterWidth < 6 mm on Palma 2 Pro (was $mm mm)", mm >= 6f) - } - - @Test fun gutterWidth_doesNotExceedMaxFraction_narrowScreen() { - // Palma 2 Pro portrait — 824 px wide at 300 PPI - init(SW_PALMA_2PRO, W_PALMA_2PRO, H_PALMA_2PRO) - val fraction = ScreenMetrics.gutterWidth / W_PALMA_2PRO.toFloat() - assertTrue("gutter fraction $fraction exceeds 0.12 on Palma 2 Pro portrait", fraction <= 0.12f) - } - - @Test fun gutterWidth_isConsistentAcrossStandardDevices() { - // All standard devices share the same density, so gutter pixel values should be equal - // (barring screen-width cap, which doesn't apply at these resolutions). - init(SW_TAB_X_C, W_TAB_X_C, H_TAB_X_C) - val tabXC = ScreenMetrics.gutterWidth - init(SW_NOTE_5C, W_NOTE_5C, H_NOTE_5C) - val note5C = ScreenMetrics.gutterWidth - assertEquals("Standard devices at same density should have equal gutter px", tabXC, note5C, 1f) - } - // ── text sizes ─────────────────────────────────────────────────────────── @Test fun textBody_isReadable_allDevices() { @@ -251,7 +212,6 @@ class ScreenMetricsTest { init(sw, w, h) assertTrue("lineSpacing <= 0 on sw=$sw", ScreenMetrics.lineSpacing > 0f) assertTrue("topMargin <= 0 on sw=$sw", ScreenMetrics.topMargin > 0f) - assertTrue("gutterWidth <= 0 on sw=$sw", ScreenMetrics.gutterWidth > 0f) assertTrue("strokeWidth <= 0 on sw=$sw", ScreenMetrics.strokeWidth > 0f) assertTrue("textBody <= 0 on sw=$sw", ScreenMetrics.textBody > 0f) assertTrue("textLogo <= 0 on sw=$sw", ScreenMetrics.textLogo > 0f) @@ -264,9 +224,15 @@ class ScreenMetricsTest { @Test fun extremelyLowDensity_doesNotCrash() { // Clamps to minimum 0.5 internally - ScreenMetrics.init(0.3f, 0.3f, 400, 800, 600) + ScreenMetrics.init(0.3f, fontScale = 0.3f, smallestWidthDp = 400, widthPixels = 800, heightPixels = 600) assertTrue(ScreenMetrics.lineSpacing > 0f) - assertTrue(ScreenMetrics.gutterWidth > 0f) + } + + @Test fun fontScale_2x_doublesTextSizes() { + ScreenMetrics.init(DENSITY, fontScale = 1f, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) + val baseTextBody = ScreenMetrics.textBody + ScreenMetrics.init(DENSITY, fontScale = 2f, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) + assertEquals("textBody at fontScale=2 should be ~2x default", baseTextBody * 2f, ScreenMetrics.textBody, 0.1f) } // ── compact-mode classification ─────────────────────────────────────────── diff --git a/app/src/test/java/com/writer/view/TouchFilterTest.kt b/app/src/test/java/com/writer/view/TouchFilterTest.kt new file mode 100644 index 0000000..5393a04 --- /dev/null +++ b/app/src/test/java/com/writer/view/TouchFilterTest.kt @@ -0,0 +1,283 @@ +package com.writer.view + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [TouchFilter]. + * + * Uses the plain-float [ScreenMetrics.init] overload so no Android framework + * dependency is needed — these run on the JVM with plain JUnit. + */ +class TouchFilterTest { + + companion object { + private const val DENSITY = 1.875f // 300 PPI Boox devices + } + + private lateinit var filter: TouchFilter + + @Before + fun setUp() { + ScreenMetrics.init(DENSITY, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + filter = TouchFilter( + palmSizeThresholdDp = 40f, + penCooldownMs = 150L, + stationarySlopDp = 8f, + stationaryTimeoutMs = 200L, + ) + } + + // ── Layer 1: Pen active ──────────────────────────────────────────────── + + @Test + fun rejects_finger_when_pen_is_down() { + filter.penActive = true + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_finger_when_pen_is_not_down() { + filter.penActive = false + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Layer 2: Pen cooldown ────────────────────────────────────────────── + + @Test + fun rejects_finger_during_pen_cooldown() { + filter.penUpTimestamp = 900L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_finger_after_pen_cooldown() { + filter.penUpTimestamp = 800L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_finger_exactly_at_cooldown_boundary() { + filter.penUpTimestamp = 850L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Layer 3: Palm size ───────────────────────────────────────────────── + + @Test + fun rejects_large_touch_area() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 50f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_small_touch_area() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 15f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun rejects_touch_at_exact_threshold() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 40.1f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── Layer 4: Multi-touch ─────────────────────────────────────────────── + + @Test + fun rejects_multi_touch() { + val result = filter.evaluateDown( + pointerCount = 2, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── Layer 5: Stationary timeout (canvas) ─────────────────────────────── + + @Test + fun rejects_stationary_finger_on_canvas_after_timeout() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved, 250ms later + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1250L, + x = 100f, y = 100f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_stationary_finger_on_text_view() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved, 250ms later — but checkStationary is false + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1250L, + x = 100f, y = 100f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_moving_finger_on_canvas_before_timeout() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved much yet, within timeout + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1100L, + x = 101f, y = 101f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_finger_that_moves_past_slop_on_canvas() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Move well past slop (8dp * 1.875 = 15px, move 50px) + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1300L, + x = 100f, y = 150f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Move-time checks ─────────────────────────────────────────────────── + + @Test + fun rejects_move_when_pen_goes_down() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + filter.penActive = true + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun rejects_move_when_touch_grows() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 50f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun rejects_move_when_second_finger_appears() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + val result = filter.evaluateMove( + pointerCount = 2, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── hasMovedPastSlop ─────────────────────────────────────────────────── + + @Test + fun hasMovedPastSlop_false_initially() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(false, filter.hasMovedPastSlop()) + } + + @Test + fun hasMovedPastSlop_true_after_large_move() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = true + ) + assertEquals(true, filter.hasMovedPastSlop()) + } + + // ── Scroll acceptance (full sequence) ────────────────────────────────── + + @Test + fun full_scroll_sequence_accepted() { + // Down + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 400f, y = 500f + ) + ) + // Small move (within slop) + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1020L, + x = 400f, y = 505f, checkStationary = true + ) + ) + assertEquals(false, filter.hasMovedPastSlop()) + + // Larger move (past slop) + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1040L, + x = 400f, y = 550f, checkStationary = true + ) + ) + assertEquals(true, filter.hasMovedPastSlop()) + } + + // ── Tap acceptance on text view ──────────────────────────────────────── + + @Test + fun tap_accepted_on_text_view() { + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 200f, y = 300f + ) + ) + // Stationary for 300ms — but text view doesn't check stationary + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1300L, + x = 200f, y = 300f, checkStationary = false + ) + ) + } +} diff --git a/docs/code-review.md b/docs/code-review.md new file mode 100644 index 0000000..030677c --- /dev/null +++ b/docs/code-review.md @@ -0,0 +1,53 @@ +# Local Code Review + +Run Claude Code reviews locally using your already-authenticated `claude` CLI, then optionally post results to a PR. No CI secrets or OAuth tokens needed. + +## Prerequisites + +- `claude` CLI installed and logged in +- `gh` CLI authenticated with your GitHub account + +## Interactive use + +```bash +# Review local changes (prompts to post if a PR exists) +./scripts/review-pr.sh + +# Review a specific PR +./scripts/review-pr.sh 42 + +# Check addressed items +./scripts/review-check.sh [pr-number] +``` + +## Non-interactive / agent use + +Both scripts accept `--local`, `--post`, `--no-post`, and `--base ` flags: + +```bash +# Review local branch diff, no remote needed +REVIEW=$(./scripts/review-pr.sh --local --no-post) + +# Review against a different base branch +REVIEW=$(./scripts/review-pr.sh --local --no-post --base develop) + +# After fixing issues, verify locally (use same --base if non-default) +./scripts/review-check.sh --local --no-post "$REVIEW" + +# Once ready, create PR and post results +gh pr create --draft +./scripts/review-pr.sh --post +./scripts/review-check.sh --post "$REVIEW" +``` + +## Automated PR cycle (used by Claude agent) + +The full self-review cycle is documented in `CLAUDE.md` under **PR Workflow**. +Claude will automatically: + +1. Review local changes with `--local --no-post` (no remote needed) +2. Address all actionable items found +3. Verify fixes with `review-check.sh --local --no-post` +4. Iterate until all items are resolved +5. Create the PR and post the final review + checklist +6. Mark the PR as ready diff --git a/scripts/rebuild-integration.sh b/scripts/rebuild-integration.sh new file mode 100644 index 0000000..d42e93b --- /dev/null +++ b/scripts/rebuild-integration.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# scripts/rebuild-integration.sh +# Rebuilds the integrate branch by merging all feature/fix/dev branches. +# Uses Claude to auto-resolve merge conflicts when they occur. +# +# Usage: ./scripts/rebuild-integration.sh [base-branch] +# base-branch: branch to start from (default: master) + +set -euo pipefail +BASE="${1:-master}" + +git branch -D integrate 2>/dev/null || true +git checkout -B integrate "$BASE" + +for branch in $(git branch --format='%(refname:short)' | grep -E "^(feature|fix|dev)/"); do + echo "Merging $branch..." + if git merge "$branch" --no-edit; then + continue + fi + + echo "Conflict merging $branch — asking Claude to resolve..." + conflicted=$(git diff --name-only --diff-filter=U) + + if [ -z "$conflicted" ]; then + echo "ERROR: merge failed but no conflicted files found. Aborting." + git merge --abort + exit 1 + fi + + echo "Conflicted files:" + echo "$conflicted" + + # Ask Claude to resolve each conflicted file + claude --print \ + --allowedTools 'Read,Edit,Bash(git add *)' \ + -p "You are resolving merge conflicts on the integrate branch. +We are merging branch '$branch' into integrate (based on '$BASE'). +The following files have conflicts: +$conflicted + +For each file: +1. Read it to see the conflict markers (<<<<<<< ======= >>>>>>>) +2. Resolve by keeping the intent of BOTH sides — do not drop changes from either branch +3. Remove all conflict markers +4. Run: git add + +Do NOT commit. Just resolve and stage." + + # Verify no conflicts remain + remaining=$(git diff --name-only --diff-filter=U) + if [ -n "$remaining" ]; then + echo "ERROR: Claude failed to resolve all conflicts. Remaining:" + echo "$remaining" + git merge --abort + exit 1 + fi + + git commit --no-edit + echo "Resolved and committed merge of $branch." +done + +echo "Integration branch ready." diff --git a/scripts/review-check.sh b/scripts/review-check.sh new file mode 100644 index 0000000..7043e05 --- /dev/null +++ b/scripts/review-check.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Check which review items have been addressed by subsequent changes. +# Usage: ./scripts/review-check.sh [--post] [--no-post] [--local] [--base ] [pr-number] +# +# Options: +# --base Base branch to diff against (default: master) +# --local Compare against local diff (no PR needed) +# --post Post update to PR without prompting (non-interactive) +# --no-post Save update locally without posting (non-interactive) +# (default) Prompt whether to post + +set -euo pipefail + +POST_MODE="" +LOCAL_MODE="" +BASE_BRANCH="master" +POSITIONAL=() + +while [ $# -gt 0 ]; do + case "$1" in + --post) POST_MODE="yes" ;; + --no-post) POST_MODE="no" ;; + --local) LOCAL_MODE="yes" ;; + --base) BASE_BRANCH="${2:?--base requires a branch name}"; shift ;; + --base=*) BASE_BRANCH="${1#--base=}" ;; + *) POSITIONAL+=("$1") ;; + esac + shift +done + +REVIEW_FILE="${POSITIONAL[0]:?Usage: review-check.sh [options] [pr-number]}" +PR="${POSITIONAL[1]:-}" + +if [ ! -f "$REVIEW_FILE" ]; then + echo "Error: review file not found: ${REVIEW_FILE}" >&2 + exit 1 +fi + +# Determine if we're working locally or with a PR +if [ "$LOCAL_MODE" = "yes" ]; then + PR="" +elif [ -z "$PR" ]; then + PR=$(gh pr view --json number -q .number 2>/dev/null) || { + LOCAL_MODE="yes" + } +fi + +# Get the current diff +if [ "$LOCAL_MODE" = "yes" ] || [ -z "$PR" ]; then + # Include committed + staged + unstaged changes vs base branch + DIFF=$(git diff "${BASE_BRANCH}") +else + DIFF=$(gh pr diff "$PR") +fi + +REVIEW=$(cat "$REVIEW_FILE") +REPO_NAME=$(basename "$(git rev-parse --show-toplevel)") + +echo "Checking which review items have been addressed..." >&2 + +# Use printf to avoid shell interpretation of diff/review content +UPDATE=$({ + printf 'You were given this code review for %s:\n\n' "$REPO_NAME" + printf '%s\n' "$REVIEW" + printf '\nHere is the current diff after the author made changes:\n\n```diff\n' + printf '%s\n' "$DIFF" + printf '```\n\n' + printf '%s\n' \ + "For each item in the original review, determine whether it has been addressed" \ + "by the current changes. Output a checklist in markdown:" \ + "" \ + "- [x] Item — addressed (brief explanation)" \ + "- [ ] Item — still open (brief explanation)" \ + "" \ + "Be concise. Only list items that were actual action items from the review." +} | claude --print --output-format text) + +echo "$UPDATE" >&2 + +# Post to PR if applicable +if [ -n "$PR" ]; then + if [ -z "$POST_MODE" ]; then + read -r -p "Post update to PR #${PR}? [y/N] " POST_MODE + [[ "$POST_MODE" =~ ^[Yy] ]] && POST_MODE="yes" || POST_MODE="no" + fi + + if [ "$POST_MODE" = "yes" ]; then + gh pr comment "$PR" --body "## Review Update + +${UPDATE} + +--- +*Checked via \`scripts/review-check.sh\`*" + echo "Posted update to PR #${PR}." >&2 + fi +elif [ "$POST_MODE" = "yes" ]; then + echo "Warning: --post ignored, no PR exists for this branch." >&2 +fi diff --git a/scripts/review-pr.sh b/scripts/review-pr.sh new file mode 100644 index 0000000..4a44242 --- /dev/null +++ b/scripts/review-pr.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Run Claude Code review on the current branch's changes. +# Usage: ./scripts/review-pr.sh [--post] [--no-post] [--local] [--base ] [pr-number] +# +# Options: +# --base Base branch to diff against (default: master) +# --local Review local diff against base branch (no PR or remote needed) +# --post Post review to PR without prompting (non-interactive) +# --no-post Save review locally without posting (non-interactive) +# (default) --local if no PR exists, prompts to post if PR exists +# +# Review output is saved to .claude/reviews/-.md +# Prints the review file path as the last line of stdout. + +set -euo pipefail + +POST_MODE="" +LOCAL_MODE="" +BASE_BRANCH="master" +PR="" + +while [ $# -gt 0 ]; do + case "$1" in + --post) POST_MODE="yes" ;; + --no-post) POST_MODE="no" ;; + --local) LOCAL_MODE="yes" ;; + --base) BASE_BRANCH="${2:?--base requires a branch name}"; shift ;; + --base=*) BASE_BRANCH="${1#--base=}" ;; + *) PR="$1" ;; + esac + shift +done + +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Determine if we're working locally or with a PR +if [ "$LOCAL_MODE" = "yes" ]; then + PR="" +elif [ -z "$PR" ]; then + PR=$(gh pr view --json number -q .number 2>/dev/null) || { + LOCAL_MODE="yes" + } +fi + +REVIEW_DIR=".claude/reviews" +mkdir -p "$REVIEW_DIR" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-') +REVIEW_FILE="$REVIEW_DIR/${SAFE_BRANCH}-${TIMESTAMP}.md" + +# Get the diff +if [ "$LOCAL_MODE" = "yes" ]; then + echo "Reviewing local branch '${BRANCH}' against '${BASE_BRANCH}'..." >&2 + # Include committed + staged + unstaged changes vs base branch + DIFF=$(git diff "${BASE_BRANCH}") + CONTEXT="Local branch: ${BRANCH} (vs ${BASE_BRANCH})" +else + echo "Reviewing PR #${PR} on branch '${BRANCH}'..." >&2 + DIFF=$(gh pr diff "$PR") + CONTEXT="PR #${PR} on branch: ${BRANCH}" +fi + +if [ -z "$DIFF" ]; then + echo "No changes found to review." >&2 + exit 0 +fi + +REPO_NAME=$(basename "$(git rev-parse --show-toplevel)") + +# Use printf to avoid shell interpretation of diff content +{ + printf 'PROJECT: %s\n%s\n\nHere is the diff:\n\n```diff\n' "$REPO_NAME" "$CONTEXT" + printf '%s\n' "$DIFF" + printf '```\n\n' + printf '%s\n' \ + "Please review these changes and provide feedback on:" \ + "- Code quality and best practices" \ + "- Potential bugs or issues" \ + "- Performance considerations" \ + "- Security concerns" \ + "- Test coverage" \ + "" \ + "Format your review as a markdown document with sections for each concern found." \ + "If everything looks good, say so briefly." \ + "Do NOT post any GitHub comments — just output the review text." +} | claude --print --output-format text > "$REVIEW_FILE" + +echo "Review saved to ${REVIEW_FILE}" >&2 + +# Post to PR if applicable and requested +if [ -n "$PR" ]; then + if [ -z "$POST_MODE" ]; then + read -r -p "Post review to PR #${PR}? [y/N] " POST_MODE + [[ "$POST_MODE" =~ ^[Yy] ]] && POST_MODE="yes" || POST_MODE="no" + fi + + if [ "$POST_MODE" = "yes" ]; then + BODY=$(cat "$REVIEW_FILE") + gh pr comment "$PR" --body "## Claude Code Review + +${BODY} + +--- +*Local review via \`scripts/review-pr.sh\`*" + echo "Posted review to PR #${PR}." >&2 + fi +elif [ "$POST_MODE" = "yes" ]; then + echo "Warning: --post ignored, no PR exists for this branch." >&2 +fi + +# Output the review file path (for piping to review-check.sh) +echo "$REVIEW_FILE"