From 869f793846ab0ba3b989e4df79a93b1fe2f9fbc6 Mon Sep 17 00:00:00 2001 From: TY J Date: Tue, 15 Sep 2026 13:08:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?requestUnbufferedDispatch=20=EB=A5=BC=20API?= =?UTF-8?q?=2030=20=EC=9D=B4=EC=83=81=EC=97=90=EC=84=9C=EB=A7=8C=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(=ED=95=84=EA=B8=B0=20=EC=B2=AB=20=ED=9A=8D=20ANR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iink 4.5 가 ACTION_DOWN 마다 새로 호출하는 requestUnbufferedDispatch 가 API 27/28/29 의 ViewRootImpl 버그를 건드려 답안패드 첫 획에서 약 20초 먹통이 되는 ANR 이 발생했다. (SM-P580 / Android 8.1 에서 재현) 원인: 해당 버전의 scheduleConsumeBatchedInputImmediately() 는 - 예약된 프레임 배치 소비를 취소하고(unscheduleConsumeBatchedInput) - 대신 실행하는 doConsumeBatchedInput(-1) 이 방금 내려간 mConsumeBatchedInputScheduled 를 가드로 쓰기 때문에 아무것도 소비하지 않는다 그 결과 네이티브에 쌓인 입력 배치가 회수되지 않아 finishInputEvent 가 불리지 않고, InputDispatcher 가 대기하다 타임아웃된다. 이때 메인스레드는 막힌 게 아니라 할 일이 없어 유휴 상태였다(실측 CPU 2.7%). API 30 에서 수정된 프레임워크 버그이며 27/28/29 소스 대조로 확인했다. 3.0.2 에는 이 호출 자체가 없어 롤백 후 증상이 사라졌다. API 30 이상에서는 저지연 캡처를 그대로 유지한다. 상세: Summit-Android trouble-shootings/myscript-iink-4.5-anr-rollback.md Co-Authored-By: Claude Opus 5 (1M context) --- .../uireferenceimplementation/InputController.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/myscript-iink/UIReferenceImplementation/src/main/java/com/myscript/iink/uireferenceimplementation/InputController.java b/myscript-iink/UIReferenceImplementation/src/main/java/com/myscript/iink/uireferenceimplementation/InputController.java index d732d66..2298035 100644 --- a/myscript-iink/UIReferenceImplementation/src/main/java/com/myscript/iink/uireferenceimplementation/InputController.java +++ b/myscript-iink/UIReferenceImplementation/src/main/java/com/myscript/iink/uireferenceimplementation/InputController.java @@ -3,6 +3,7 @@ package com.myscript.iink.uireferenceimplementation; import android.content.Context; +import android.os.Build; import android.os.SystemClock; import android.util.Log; import android.view.GestureDetector; @@ -188,7 +189,14 @@ else if (inputMode == INPUT_MODE_FORCE_TOUCH) ToolController toolController = editor.getToolController(); PointerTool tool = toolController.getToolForType(iinkPointerType); if (tool == PointerTool.PEN || tool == PointerTool.HIGHLIGHTER) - editorView.requestUnbufferedDispatch(event); + { + // API 27/28/29 의 ViewRootImpl 은 "즉시 배치 소비"(scheduleConsumeBatchedInputImmediately)가 + // 예약된 프레임 소비를 취소만 하고 실제로는 아무것도 소비하지 않는다. 그 결과 배치된 입력이 + // 영영 회수되지 않아 입력 디스패치 타임아웃 ANR 이 발생한다. API 30 에서 수정됨. + // → trouble-shootings/myscript-iink-4.5-anr-rollback.md + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + editorView.requestUnbufferedDispatch(event); + } try { From a7ec140a157a52e9a6fc043a0648f22b80490138 Mon Sep 17 00:00:00 2001 From: TY J Date: Tue, 15 Sep 2026 14:09:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?JIIX=20operands/expressions=20=EC=9D=98=20n?= =?UTF-8?q?ull=20=EC=9B=90=EC=86=8C=20=EB=8C=80=EC=9D=91=20(=EC=88=98?= =?UTF-8?q?=EC=8B=9D=20=ED=8E=B8=EC=A7=91=20=EC=A7=84=EC=9E=85=20=EC=8B=9C?= =?UTF-8?q?=20NPE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답안패드에 "2+" 처럼 피연산자 한쪽을 비운 채 candidate 버튼을 누르면 Expression.getOperands() NPE 로 죽는 크래시가 iink 4.5.0 이후 발생했다. 원인: 미해결 피연산자 자리를 두 버전이 다르게 직렬화한다. - 3.0.2 : {"type":"number","label":"?","generated":true,"error":"Unsolved"} - 4.5.0 : null Gson 은 Kotlin non-null 제네릭을 강제하지 않아 List 안에 java null 이 그대로 들어오고, getAllExpressions 의 평탄화에서 터진다. 원소 타입을 nullable 로 선언해 wire 포맷을 있는 그대로 반영하고, 순회하는 쪽에서 filterNotNull 로 걸러낸다. getAllExpressions 의 반환 타입은 List 그대로라 하위 호출부는 수정이 없다. 3.0.2 의 플레이스홀더 객체는 id/items/symbols 가 모두 없어 순회에 아무것도 기여하지 않았으므로, null 을 버리는 것은 3.0.2 동작의 복원이다. changeItem 은 null 을 자리 그대로 보존해 import_ 라운드트립 구조를 유지한다. 다만 Gson 은 배열 안 null 을 리터럴 null 로 직렬화하므로 iink 임포터가 이를 받는지는 실기기 확인이 필요하다. 상세: Summit-Android trouble-shootings/myscript-iink-4.5-jiix-null-operand-npe.md Co-Authored-By: Claude Opus 5 (1M context) --- .../knowre/android/myscript/iink/jiix/Expression.kt | 12 ++++++++++-- .../com/knowre/android/myscript/iink/jiix/Jiix.kt | 7 ++++--- .../android/myscript/iink/jiix/JiixExtension.kt | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/Expression.kt b/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/Expression.kt index 2a567dc..f7088e3 100644 --- a/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/Expression.kt +++ b/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/Expression.kt @@ -14,7 +14,15 @@ data class Expression( @SerializedName("bounding-box") val boundingBox: BoundingBox?, val symbols: List?, - val operands: List? + /** + * iink 4.5 부터 **미해결 피연산자 자리**(예: `2+` 처럼 한쪽을 비운 입력)를 + * 객체가 아닌 `null` 로 직렬화한다. 3.0.2 는 같은 자리를 + * `{"type":"number","label":"?","generated":true,"error":"Unsolved"}` 객체로 내보냈다. + * + * Gson 은 Kotlin 의 non-null 제네릭을 강제하지 않아 타입 선언만으로는 막을 수 없으므로, + * 원소 타입을 nullable 로 선언하고 순회하는 쪽에서 걸러낸다. + */ + val operands: List? ) internal fun Expression.changeItem(newItem: Item): Expression = @@ -23,6 +31,6 @@ internal fun Expression.changeItem(newItem: Item): Expression = if (item.id == newItem.id) newItem else item }, operands = operands?.map { operand -> - operand.changeItem(newItem) + operand?.changeItem(newItem) } ) diff --git a/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/Jiix.kt b/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/Jiix.kt index 5d89aab..d1a1162 100644 --- a/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/Jiix.kt +++ b/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/Jiix.kt @@ -7,7 +7,8 @@ data class Jiix( val id: String, val type: String, val items: List?, - val expressions: List?, + /** 원소가 null 일 수 있는 이유는 [Expression.operands] 주석 참고. */ + val expressions: List?, @SerializedName("bounding-box") val boundingBox: BoundingBox?, val version: String @@ -15,7 +16,7 @@ data class Jiix( fun Jiix.isValid() = !expressions.isNullOrEmpty() && - expressions.all { it.id != null } && + expressions.all { it?.id != null } && getAllItems().all { it.isValid } fun Jiix.changeItem(itemId: String, func: Item.() -> Item) = @@ -27,6 +28,6 @@ internal fun Jiix.changeItem(newItem: Item) = if (item.id == newItem.id) newItem else item }, expressions = expressions?.map { expression -> - expression.changeItem(newItem) + expression?.changeItem(newItem) } ) diff --git a/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/JiixExtension.kt b/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/JiixExtension.kt index 4f4a7fc..2dc0144 100644 --- a/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/JiixExtension.kt +++ b/myscript-iink/src/main/java/com/knowre/android/myscript/iink/jiix/JiixExtension.kt @@ -8,9 +8,9 @@ import java.util.Collections //region Jiix internal fun Jiix.getAllExpressions(): List { fun List.flatten(): List = - this + this.flatMap { it.operands?.flatten() ?: Collections.emptyList() } + this + this.flatMap { it.operands?.filterNotNull()?.flatten() ?: Collections.emptyList() } - return this.expressions?.flatten() ?: Collections.emptyList() + return this.expressions?.filterNotNull()?.flatten() ?: Collections.emptyList() } fun Jiix.getCandidates(item: Item): List {